Skip to content

💥 Modernize the toolchain and ship dual ESM + CJS - #17

Merged
SylvainEstevez merged 7 commits into
mainfrom
chore/modernize
Aug 31, 2026
Merged

SylvainEstevez merged 7 commits into
mainfrom
chore/modernize

Conversation

@SylvainEstevez

Copy link
Copy Markdown
Collaborator

Fifth repo through the modernization runbook in the workspace CHECKLIST.md, after
url (the pilot), thrown, http-method and schema. pnpm · tsdown (dual ESM + CJS) ·
Vitest · Biome · TypeScript 7 · shared CI from tselect-npm/.github@v1.

The title is :boom: deliberately. Squash is the only merge method the rulesets
allow, so this PR collapses to one commit and that commit's gitmoji is the only one
the release workflow reads. :wrench: would infer as no release at all. The branch's
series is preserved below, since this is the only place it survives.

:bug:                 Stop shipping a 1.35 MB stray log in the published package
:wrench:              Migrate to pnpm
:sparkles:            Add dual ESM + CJS build via tsdown, on TypeScript 7
:white_check_mark:    Migrate test suite to Vitest
:rotating_light:      Replace TSLint with Biome
:memo:                Add LICENSE, a full README API section and correct metadata
:construction_worker: Run CI from the org-wide reusable workflow

Headline fix: the published package ships a 1.35 MB log

@tselect/status-code@1.0.2 is 32 files and 1,369,746 bytes unpacked, of which
1,352,289 are a single .log22069 left over from a 2023 test run. tslint.json rode
along too. The .gitignore had .log*, so the file was invisible to git status while
npm included it in every publish — the registry tarball is the only thing that tells you
what actually ships.

The cause is .npmignore: a denylist cannot enumerate files that did not exist when it
was written. Replaced with "files": ["dist"]. The tarball is now 9 files and
7,970 bytes
.

Also fixed: prepare: "tsc" was a silent no-op, because tsconfig.json sets
noEmit: true. A clean checkout would have packed an empty dist/; it only worked
because dist/ happened to exist in the publishing tree.

Why this is a major

  1. engines.node is now ">=22" — the bottom of the CI matrix, so the floor is
    tested by construction. >=20 is not viable regardless: pnpm 11 requires
    node >=22.13.
  2. The exports map closes deep imports. No consumers of
    @tselect/status-code/dist exist anywhere public — the search returns nothing for
    the package at all.
  3. isStatusCode(value: any) becomes isStatusCode(value: unknown). This is a
    widening, so no existing call stops compiling, but it is a public signature change.

Coverage, and a quirk pinned rather than fixed

The inherited suite reported 100% of statements, functions and lines but only 90% of
branches
, below the 95% floor. Coverage is now 100% of all four, via seven new
assertions in five tests. The gaps were toString's || null fallback and
isConsumerErrorStatusCode's non-status-code path. That is the third repo running where
the statement column looked finished and the branch column did not.

Two of the new tests pin behaviour rather than assert intent. StatusCode is a numeric
enum, so its runtime object carries a reverse mapping and Object.values yields the 43
member names alongside the 43 numbers:

isStatusCode('OK');                      // true  — not a typo
toString('OK' as unknown as StatusCode); // '200'

That has been the published behaviour since 1.0.0, so this tooling refresh preserves
it
rather than quietly changing a type guard's answers. It is now documented under a
warning in the README, with the typeof value === 'number' && isStatusCode(value)
workaround, and pinned by tests that explain the cause.

toString also trips Biome's noShadowRestrictedNames, twice, and stays — it is the
published export name, and renaming it would break every consumer for a cosmetic gain.
Suppressed with biome-ignore at the two sites that need it, each carrying its reason,
rather than switched off in biome.json.

Verification

Differential-tested against the published 1.0.2 tarball after every source-touching
commit: 115 inputs (all 43 enum values, all 43 member names, and 29 edge cases including
NaN, Infinity, null, undefined, '200', 'OK', booleans, {} and []) across
all 11 functions, run against both new entry points, plus enum contents and export
names — 2,534 comparisons, 0 mismatches.

Every README example was executed rather than just written: 210 assertions, all
passing, typechecking clean under strict + nodenext. The three prose claims were
checked across the whole enum rather than at one example each — that
isServerErrorStatusCode/isConsumerErrorStatusCode are aliases of is5xx/is4xx,
that isNonErrorStatusCode is the complement of isErrorStatusCode over valid codes,
and that "at or above 400" is exactly 4xx ∪ 5xx.

From a clean clone of this branch: 34 tests, 100% coverage on all four metrics,
pnpm audit 0 (from 15), biome check and tsc --noEmit clean, attw clean on all
four resolution modes, es-check es2015 on both bundles, both declaration files non-empty
at 2,624 bytes, and require() and import agreeing on all 12 exports.

Notes

  • No tag surgery needed. Unlike the other repos in this scope, v1.0.2 already sits
    on a1569df — exactly the commit the registry reports as 1.0.2's gitHead — so the
    release workflow's commit range resolves on the first try. The 17 remaining
    @bluejay-era tags are clutter, but none collides with the v2.0.0 this major wants,
    so they are left alone.
  • master was renamed to main as part of this work. That retargeted the stale
    Dependabot PR ⬆️ Bump serialize-javascript and mocha #16 automatically; it is moot once this merges, since it patches a
    package-lock.json that no longer exists for a mocha that is no longer a dependency.
  • Publishing is deferred until all seven repos are modernized. The version stays
    1.0.2 and the major goes out with the others at the end.

`@tselect/status-code@1.0.2` ships 32 files and 1,369,746 bytes unpacked, of
which 1,352,289 are a single `.log22069` left over from a 2023 test run. The
`.gitignore` had `.log*`, so the file was invisible to `git status` while npm
included it in every publish — the registry tarball is the only thing that
tells you what actually ships. `tslint.json` rode along too.

The cause is `.npmignore`, which is a denylist and so cannot enumerate files
that did not exist when it was written. It listed `test/`, `node_modules/`,
`.idea/`, `tsconfig.json`, `*.ts` and `.nyc_output/`; a stray log matched none
of them. Replaced with `"files": ["dist"]`, an allowlist, which cannot fail
open in this direction.

Also fixes `prepare: "tsc"`, which was a silent no-op: `tsconfig.json` sets
`noEmit: true`, so it compiled nothing and a clean checkout would pack an
empty `dist/`. It only worked because `dist/` happened to be present in the
publishing tree. Replaced with `prepack: "npm run build"`, which passes
`--noEmit false`.

`.gitignore` is now the same list the other repos use. Note it drops `.log*`
in favour of `*.log`, which deliberately does *not* hide `.log22069`-style
names — with an allowlist guarding the tarball, a stray log is better left
visible to `git status` than silently ignored.

Verified by packing a clean copy of the tracked tree: 30 files and 5,397
bytes packed, and the tarball installed into a scratch consumer still exposes
the same 12 exports and 86 enum keys as the published 1.0.2.
Drops `package-lock.json` for `pnpm-lock.yaml` and declares
`packageManager: pnpm@11.21.0`, matching the four repos already migrated.

Note pnpm 11 keeps its settings in `pnpm-workspace.yaml` rather than a `pnpm`
key in `package.json` — this is not a workspace, the file is just where the
settings live now. It carries the `esbuild >=0.28.1` override the other repos
use: vitest still resolves into the range covered by GHSA-g7r4-m6w7-qqqr, and
this pins it forward until vitest widens. Dev-only; nothing ships.

Resolving the existing ranges against already-published patches takes
`audit` from **15 vulnerabilities (2 low, 4 moderate, 9 high) to 3 (2
moderate, 1 high)** with no version-range edits at all. The remaining three
are all under `nyc`, which the Vitest migration removes.

Tests still report the baseline 29 passing.
Replaces the file-per-module `tsc` output with a bundled dual build, and adds
the `exports` map. `main`/`module`/`types` stay for older resolvers.

**Steps 3 and 6 of the checklist are one commit deliberately.** They cannot be
sequenced apart: tsdown's peer range is `^5 || ^6 || ^7`, so it refuses to run
on the old `typescript@4.9.5`, and TypeScript 7 removes
`moduleResolution: "node10"` (TS5108), so the old tsc build cannot survive the
upgrade either. Whichever landed first would leave the tree unbuildable.

TypeScript 4.9.5 → 7.0.2 and `@types/node` 12.7.0 → 22.x. `tsconfig.json`
moves to `moduleResolution: "bundler"`, gains `skipLibCheck` (required once
`include` covers `test/`) and drops `suppressImplicitAnyIndexErrors`, which
TypeScript 5.5 removed.

That last one is not cosmetic — it was hiding a real error in `toString`:

    Object.keys(StatusCode).find(key => StatusCode[key] === code)

`StatusCode` is a numeric enum, so at runtime it holds both directions of the
mapping (`{ 200: 'OK', OK: 200 }`) and indexing it by an arbitrary string is
meaningful — but that is not expressible in the enum's own type, so TS 7
reports TS7015. Fixed by naming the runtime shape in a typed alias rather than
by re-suppressing the error or casting at the call site. This is why the
function returns `'200'` for `toString('OK')`, which is preserved exactly.

Emitted syntax stays at ES2015, matching what `target: es6` produced before;
CI asserts it with es-check.

Verified against the published 1.0.2 tarball: 115 inputs (all 43 enum values,
all 43 member names, and 29 edge cases including `NaN`, `Infinity`, `null`,
`undefined`, `'200'`, `'OK'`, booleans, `{}` and `[]`) across all 11 functions,
run against **both** new entry points, plus enum contents and export names —
**2,534 comparisons, 0 mismatches**. `attw` is clean on all four resolution
modes, es-check passes es2015 on both bundles, and `dist/index.d.mts` and
`dist/index.d.cts` are both present at 2,620 bytes.
Removes mocha, chai, nyc, ts-node, @types/mocha and @types/chai; adds vitest
and @vitest/coverage-v8. `test/mocha.opts`, `test/setup.ts` and
`test/global.d.ts` go with them — the last two existed only to hang chai's
`expect` off `global`, which Vitest makes unnecessary.

The port is mechanical: `describe`/`it` stay, `.to.equal(x)` becomes
`.toBe(x)`, and the `<any>` angle-bracket casts become `as` casts. All **29
baseline assertions are preserved verbatim** — diffed pairwise rather than
eyeballed, with none missing.

**`pnpm audit` reaches 0**, from 3 after the pnpm migration and 15 before it.
All three remaining advisories were under `nyc`, so deleting it achieves this
by subtraction rather than by `audit fix`.

Seven assertions are new, in five tests, and they close a real gap: the
inherited suite reported 100% of statements, functions and lines but only
**90% of branches**, below the 95% shared floor. Coverage is now **100% of
all four**. The gaps were:

* `toString`'s `|| null` fallback — never exercised, so nothing pinned what
  the function does with a code outside the enum.
* `isConsumerErrorStatusCode`'s non-status-code path, the only one of the
  eight predicates whose `false` case was untested.

Two of the new tests pin behaviour rather than assert intent, and say so at
the site. `StatusCode` is a numeric enum, so its runtime object carries a
reverse mapping and `Object.values` yields the 43 member *names* alongside the
43 numbers. `isStatusCode('OK')` is therefore `true` and `toString('OK')` is
`'200'`. That has been the published behaviour since 1.0.0; this is a tooling
refresh, so it is locked in place rather than quietly corrected.

This is the third repo in a row where the statement column looked finished and
the branch column did not.
Deletes `tslint.json` and the `tslint` dependency (EOL since 2019) for
`@biomejs/biome`, which covers lint *and* format in one tool. The `$schema`
pin is 2.5.11 here rather than the 2.5.8 the other repos carry — Biome rejects
a config whose schema version does not match the CLI.

Biome's recommended set found 13 issues across three rules. Ten were
`useImportType` and fixed mechanically, along with an export sort in
`src/index.ts` and a reformat of the enum. The other three needed decisions:

**`isStatusCode(value: any)` → `unknown`.** A type guard exists to narrow a
value whose type nobody has established, and `any` defeats that at every call
site. This widens what the signature accepts, so no existing call stops
compiling. `Object.values` of a numeric enum is typed `(string | StatusCode)[]`
and its `includes` will not take an `unknown`, so the lookup now casts — no
behaviour change.

**`toString` shadows a global, twice, and stays.** It is the published export
name and has been since 1.0.0; renaming it would break every consumer for a
cosmetic gain, and the rule's real hazard — confusion with
`Object.prototype.toString` — does not apply to a named module export always
reached through an explicit import. Suppressed with `biome-ignore` at the two
sites that need it, each carrying the reason, rather than switched off in
`biome.json`, so a genuine future shadowing is still reported.

Every one of these touches source, so the differential suite was re-run rather
than reasoned about: the same 115 inputs × 11 functions against both new entry
points, **2,534 comparisons, 0 mismatches**, with enum contents and export
names unchanged. `tsc --noEmit` is clean, 34 tests pass, coverage holds at
100% of all four metrics.
The repo had no `LICENSE` file at all despite `"license": "MIT"` in
`package.json` and an MIT badge in the README. Adds the same MIT text the
other repos carry.

README follows the shared order — title, badges, description, Requirements,
Installation, Usage, API, License — and gains a real API section: all 43 enum
members grouped by class, and every one of the 11 functions with a signature
and a runnable example. It also documents the reverse-mapping behaviour of
`isStatusCode` and `toString` under a warning, with the
`typeof value === 'number' && isStatusCode(value)` workaround, rather than
leaving consumers to discover it.

Metadata fixes: `bugs.url` was missing `/issues`, so the badge and npm's
"report an issue" link both pointed at the repo root; `description` now says
what the package contains; `keywords` gains `http-status`, `status-code`,
`status`, `esm` and `cjs`.

**Every README example was executed, not just written.** 210 assertions, all
passing, and the file typechecks clean under `strict` + `nodenext` with no
casts beyond the ones the README itself shows. The three prose claims were
checked across the whole enum rather than at one example each — that
`isServerErrorStatusCode`/`isConsumerErrorStatusCode` are aliases of
`is5xx`/`is4xx`, that `isNonErrorStatusCode` is the complement of
`isErrorStatusCode` over valid codes, and that "at or above 400" is exactly
4xx ∪ 5xx — all 43 members each.
Adds `ci.yml` and `publish.yml` as thin callers of the reusable workflows in
`tselect-npm/.github`, both pinned at `@v1` and never `@main` — an edit
upstream would otherwise change this repo's behaviour with no review here.

`ci.yml` is byte-identical to `http-method`'s. The `concurrency` block has to
live in the caller: inside a reusable workflow `github.workflow` and
`github.ref` resolve to the caller's, so every repo calling it would share one
group and cancel each other. `es-check-target` is left at its default of
es2015, which is this package's declared ceiling — only `thrown` overrides it.

`publish.yml`'s filename is load-bearing: npm's trusted publisher validates
the *caller's* workflow filename, so renaming it breaks publishing with a 404
that mentions nothing about filenames. Its `since-ref` description is written
for this repo rather than copied — `status-code` needs no tag surgery, because
`v1.0.2` already sits on `a1569df`, exactly the commit the registry reports as
1.0.2's `gitHead`. That makes it the only repo of the seven whose tags were
already correct.

Declares `"engines": { "node": ">=22" }`. That is the bottom of the CI matrix,
so the floor is proven by construction rather than asserted; `>=20` is not
viable regardless, since pnpm 11 requires `node >=22.13`. **This is what makes
the release a major.**
@SylvainEstevez
SylvainEstevez merged commit fd28cb4 into main Aug 31, 2026
9 checks passed
@SylvainEstevez
SylvainEstevez deleted the chore/modernize branch August 31, 2026 11:49
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