Skip to content

💥 Modernize toolchain: pnpm, tsdown, Vitest, Biome, TypeScript 7, CI - #29

Merged
SylvainEstevez merged 8 commits into
mainfrom
chore/modernize
Aug 30, 2026
Merged

SylvainEstevez merged 8 commits into
mainfrom
chore/modernize

Conversation

@SylvainEstevez

Copy link
Copy Markdown
Collaborator

Step 0–8 of CHECKLIST.md for @tselect/schema. Fourth repo after url, thrown and http-method, taken out of turn — and the reason it was scheduled late did not survive contact with the code.

Suggested squash commit message

This repo's ruleset allows squash only, and repo settings are PR_TITLE + BLANK, so the branch's gitmoji series does not survive on its own. Paste this as the commit body at merge time:

:boom: Modernize toolchain: pnpm, tsdown, Vitest, Biome, TypeScript 7, CI

Eight concerns, one commit — this repository's ruleset allows squash merges
only, so the gitmoji series from `chore/modernize` is recorded here rather
than replayed onto `main`:

  :bug:                 Repo hygiene: fix what ships and stop tracking build artefacts
  :wrench:              Migrate to pnpm
  :heavy_minus_sign:    Drop the @types/ajv stub and demote ajv to a devDependency
  :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, keywords and a full README API section
  :construction_worker: Run CI from the org-wide reusable workflow

`:boom:` rather than the branch's `:wrench:` because a squash leaves one
commit to infer the bump from, and `:wrench:` infers as no release at all.
The `bump: major` override is still the documented way to ship this; the
history now says so on its own as well.

Why this is a major, four times over

  • engines.node becomes >=22 — the bottom of the CI matrix, so the floor is proven by construction rather than asserted.
  • The exports map closes deep imports. gh search code "@tselect/schema/dist" found no consumer doing it, but it is a break.
  • enumeration()'s return type moves from any to TJSONSchema — a compile-time break for callers relying on any to skip narrowing. Same call thrown made.
  • ajv leaves dependencies, closing a transitive install for anyone resolving it through this package rather than declaring it.

The scheduling premise was wrong: ajv is test-only

schema was ordered second-to-last because of ajv@6, on the theory that 6 → 8 was a breaking change this package owed its consumers. src/ never imports ajv. The only references are test/factories/object.test.ts and test/factories/uuid.test.ts, which compile a produced schema to prove it is valid JSON Schema; the published 1.0.0 tarball contains no reference to ajv either. It was a devDependency declared as a runtime one. Moved, upgraded to 8, and the risk the ordering existed to defer evaporated. @types/ajv@1.0.0 — a deprecated stub with "typings": null — is deleted.

Headline fix: the entire package was broken in ESM

All seven utils used import * as Lodash from 'lodash'. lodash assigns its exports dynamically, so cjs-module-lexer cannot detect them and the ESM namespace comes back empty. Because makeSchema → cleanOptions → Lodash.omit, every factory in the package would have thrown on the ESM entry while CJS kept working — the whole surface, not one function as in http-method. Fixed with the default import, which Node's CJS interop always populates.

Checked non-vacuous: restoring the namespace form makes dist/index.mjs throw Lodash.omit is not a function on string() while dist/index.cjs still returns {"type":"string"}.

lodash is kept rather than vendored — nine helpers including cloneDeep and mergeWith, which is more than the removal policy's "handful". Only the import form changed.

Two smaller API fixes, both found by running things rather than reading them

  • cleanOptions's signature made its own purpose uncallable. Its first parameter was TCommonOptions, i.e. { nullable?: boolean }, so cleanOptions({ nullable: true, title: 'T' }) — exactly what the function exists to do — failed excess property checking and would not compile. Widened to TOptions<TJSONSchema>, which is what every internal caller already passed. Strictly widening; three tests cover it, the first doubling as the compile-time guard.
  • isJSONSchemaLike(obj: {}) → obj: unknown, also strictly widening: every call that compiled before still does, and unknown values no longer need a cast at the call site.

One inconsistency pinned rather than fixed

nullable(enumSchema, false) strips 'null' from type but still appends null to enum, so the two disagree. Published 1.0.0 behaves identically, so it is not a regression — and this is a tooling refresh, so it is left alone. A test with a comment pins the current behaviour and gives the fix somewhere to land. Say the word if you would rather it were fixed here.

Verification

Everything below was run against a clean copy of the tracked tree, not the working directory. That discipline earned its keep immediately: the first attempt built ESM-only with no CJS bundle and no .d.cts, because tsdown.config.ts had been written but never git added — and the build still exited 0.

Gate Result
Differential vs published 1.0.0 105 scenarios across all 30 exports × new CJS + new ESM = 212 comparisons, 0 mismatches; export names identical across all three
README examples 65 assertions, all matched; typechecks under strict + nodenext on TS 7 with no casts on any documented call
Tests 74 passed (58 baseline + 16)
Coverage 100% statements, branches, functions, lines — branches were 89.55% before ten added tests
pnpm audit 0, down from 17, by subtraction rather than audit fix
biome check · tsc --noEmit clean
attw --profile node16 no problems, all four resolution modes
es-check es2015 clean on both bundles
Tarball 16 kB / 9 files, against the published 31 kB / 84 files

The published tarball was shipping a stray 30 kB .log32156 and tslint.json; .npmignore is replaced by "files": ["dist"], since a denylist cannot enumerate what does not exist yet.

Biome raised 16 findings, 15 in the public API. They were triaged rather than ruled on: two changed as deliberate API decisions (above), the mechanical ones fixed properly, and four anys kept with a per-line biome-ignore and the reason written at the site — TJSONSchema's [key: string]: any index signature is what makes the type open to the whole of JSON Schema, and under unknown every schema.minLength read would need narrowing. Nothing is disabled in biome.json.

Still open after this merges

  • 19 @bluejay-era tags, and none of them is v1.0.0 — an unprefixed 1.0.0, v1.1.0 through v1.9.5, and v3.0.0-beta.0. Nothing names 64abede, the commit the registry records as 1.0.0's gitHead, so the release plan job would count every commit in the history. A third variant of the same problem: url had a squatter on the tag its major wanted, thrown had no tags at all. Clearing it is a step 9 prerequisite; since-ref is the escape hatch until then.
  • Publishing, deferred until all seven repos are modernized.
  • CodeQL's actions language cannot be enabled until workflows exist on the default branch — it will pick up on the next tselect sync after this merges.

- Delete `.npmignore` in favour of `"files": ["dist"]`. The denylist was
  shipping `.log32156` (a stray 30 KB log) and `tslint.json` in every
  published tarball — a denylist cannot enumerate what does not exist yet.
- Remove the tracked `.log32156` and the committed `docs/` typedoc output.
  `docs/` was generated by typedoc 0.25 against an older API and had been
  stale for several releases; it is regenerable and does not belong in git.
- Rewrite `.gitignore`. The old one led with `**/*.js`, which swallows flat
  config files at the repo root — a later `biome.json`/`vitest.config.ts`
  would have been untracked and lint would have passed vacuously.
- Replace `prepare: "tsc"` with `prepack`. With `noEmit: true` in
  tsconfig.json the old hook was a silent no-op: a clean checkout ran it,
  emitted nothing, and packed whatever `dist/` happened to be lying around.

Verified from a clean copy of the tracked tree rather than the working
directory: pack produces 84 files, `dist/` carries `.js` alongside `.d.ts`,
and the tarball installs into a scratch consumer exposing the same 30
exports as the published 1.0.0.
Drop `package-lock.json` for `pnpm-lock.yaml` and pin `packageManager` to
pnpm@11.21.0, matching the other repos in the scope.

`pnpm-workspace.yaml` is not a workspace declaration — pnpm 11 moved its
settings out of `package.json`, so `allowBuilds` and `overrides` live here
now. The esbuild override is carried over from the other repos: dev-only,
nothing reaches consumers.

Gate: the suite still reports the baseline **58 passing**. Vulnerabilities
drop **17 → 3** on this step alone, with no dependency edits — purely from
resolving the existing ranges to already-published patches. The remaining 3
are all in the mocha/nyc trees and go away when those are removed.
`ajv` was declared as a runtime dependency but **`src/` never imports it**.
The only references in the repo are `test/factories/object.test.ts` and
`test/factories/uuid.test.ts`, which compile a produced schema to prove it
is valid JSON Schema — a test assertion, not a runtime concern. The
published `1.0.0` tarball contains no reference to ajv either. This package
consumes and produces plain schema objects; it has never validated anything.

Consequence worth recording, because the migration plan assumed otherwise:
the ajv 6 → 8 upgrade this repo was expected to owe consumers is **not a
breaking change at all**. It is a test-only bump, and it is made in the
Vitest commit alongside the rest of the test toolchain.

`@types/ajv@1.0.0` is a deprecated stub — `"typings": null`, an empty
`main`, and a dependency on `ajv` itself, which has shipped its own types
since v5. It contributed nothing but a second copy of ajv in the tree.

Removing `ajv` from `dependencies` does close a transitive install for any
consumer that was resolving it through this package rather than declaring
it, so it belongs in the major regardless.

Gate: 58 tests still pass and the 30 runtime exports are unchanged.
Steps 3 and 6 of the checklist land together because they cannot be
sequenced apart: tsdown's peer range is `^5 || ^6 || ^7` so it refuses to
run on typescript@4.9.5, and TypeScript 7 removes `moduleResolution:
node10` (TS5108) so the old tsc build cannot survive the upgrade either.
Whichever went first would leave the tree unbuildable.

Build: `tsdown` replaces `rm -rf dist && mkdir dist && tsc --noEmit false`,
emitting `dist/index.mjs` + `dist/index.cjs` with both declaration files and
sourcemaps, at `target: es2015`. The `exports` map closes deep imports —
`@tselect/schema/dist/...` no longer resolves. A code search found no
consumer doing that, but it is a breaking change and belongs in the major.

tsconfig: `moduleResolution: bundler`, `skipLibCheck` (required now that
`include` covers `test/`, or tsc typechecks Vitest's own declarations), and
`suppressImplicitAnyIndexErrors` dropped — removed in TS 5.5. The old
`exclude` list also named three files that live in `src/`
(`clean-options.ts`, `make-schema.ts`, `nullable.ts`); they were compiled
regardless, because every factory imports them transitively, so the entries
did nothing but mislead.

Three source changes, all forced by the new build:

- **lodash could not be imported the way it was.** All seven utils used
  `import * as Lodash from 'lodash'`. lodash assigns its exports
  dynamically, so `cjs-module-lexer` cannot detect them and the ESM
  namespace comes back without them. Since `makeSchema` → `cleanOptions` →
  `Lodash.omit`, *every* factory in the package would have thrown on the
  ESM entry while CJS kept working. Switched to the default import, which
  Node's CJS interop always populates. Checked non-vacuous: restoring the
  namespace form makes `dist/index.mjs` throw `Lodash.omit is not a
  function` on `string()` while `dist/index.cjs` still returns
  `{"type":"string"}`.

- **Nine type re-exports in `src/index.ts` had to become `export type`.**
  tsc accepted the value-export of a type; rolldown compiles modules in
  isolation, cannot know the symbol is type-only, and fails with
  MISSING_EXPORT. No consumer impact — none of them existed at runtime.

- **`clone-with.ts` and `merge-with.ts` imported `TObjectJSONSchema` from
  `'..'`,** i.e. from the barrel that re-exports them, a cycle back through
  `src/index.ts`. Now they import the defining module directly.

Gates, all from a clean copy of the tracked tree rather than the working
directory — which is what caught `tsdown.config.ts` being untracked on the
first attempt, where the clean copy silently fell back to tsdown's defaults
and built ESM-only with no declarations for CJS:

- `dist/index.d.mts` and `dist/index.d.cts` both exist and are non-empty.
- `attw` clean on all four resolution modes.
- `tsc --noEmit` clean over `src/` (the `test/` errors are the mocha and
  chai globals, and clear in the next commit).
- **Differential-tested against the published 1.0.0 tarball: 105 scenarios
  across all 30 exports — every factory bare, with options and with
  `nullable`; the combinators; `enumeration` over arrays, enum objects,
  mixed and invalid values; every util including the non-mutation and
  deep-clone properties; and both documented throw paths — run against the
  new CJS entry and the new ESM entry. 212 comparisons, 0 mismatches, and
  identical export names across all three.**

Tarball: 8 files / 11.9 kB, against the published 84 files / 31 kB.
Replace mocha + chai + ts-node + nyc with Vitest and `@vitest/coverage-v8`.
`test/setup.ts` (which assigned `expect` onto `global`), `test/mocha.opts`
and `test/global.d.ts` are all gone — every file imports `describe`, `it`
and `expect` explicitly now. Assertions map mechanically:
`.to.deep.equal`/`.to.eql` → `toEqual`, `.to.equal` → `toBe`,
`.to.throw` → `toThrow`.

ajv goes 6 → 8 in the same commit, since it is now a devDependency and this
is the only place it is used. Two strict-mode relaxations were needed, both
about draft-07 constructs this package deliberately emits rather than about
the schemas being wrong: `allowUnionTypes` for `nullable` schemas' `type`
arrays, and `allowMatchingProperties` for a `properties` key that also
matches a `patternProperties` pattern.

Coverage went from a `nyc` run nobody was gating on to **100% of
statements, branches, functions and lines**, against the 95% floor. Six
tests were added to get the branch column there, all for paths the old
suite never entered: `object()`'s `options.properties` override,
`nullable()`'s throw on an untyped schema, `nullable(schema, false)`, and
`omitProperties`/`pickProperties` on a schema carrying neither
`properties` nor `required`.

One of those six pins a real inconsistency rather than endorsing it:
`nullable(enumSchema, false)` strips `'null'` from `type` but still appends
`null` to `enum`, so the two disagree. The published 1.0.0 behaves
identically, so it is not a regression, and it is left alone here — this is
a tooling refresh. The test and its comment give the fix somewhere to land.

Gate: **58 tests, the same count and the same assertions as the baseline**,
plus the six new ones = 64. `pnpm audit` is now **0** — achieved by
subtraction, deleting the mocha and nyc trees, not by `audit fix`.
`tsc --noEmit` is clean over `src` and `test` together for the first time.
TSLint has been end-of-life since 2019. Biome 2.5.11 replaces it for lint
**and** format, so `tslint.json` and the `fix` script are gone and `lint`,
`lint:fix` and `format` take their place. `biome.json` is `http-method`'s,
with the current `preset: "recommended"` spelling rather than the
deprecated `recommended: true`.

Formatting is mechanical across 63 files (single quotes, trailing commas,
120 columns, sorted imports, `import type` where the import is type-only).

The lint half was not mechanical, and this is the step that needed
decisions. Biome's recommended set raised 16 findings, 15 of them in the
public API. Handled by category rather than by a rule switch — nothing is
disabled in `biome.json`:

**Fixed, no API impact**
- `to-string-reg-exp.ts` shadowed the global `toString` with a local. Renamed.
- `nullable.ts` cast its `cloneWith` overrides through `any` four times. The
  cast exists because the overrides parameter is a conditional type that
  TypeScript will not resolve against an unresolved `T`; naming it
  (`TCloneWithOverrides<T>`, exported from `clone-with.ts` but **not** from
  the barrel, so the public export list is unchanged) makes the assertion
  precise instead of unchecked. `make-schema.ts` takes `Partial<T>` for the
  same reason, and `merge-with.ts`'s lodash customizer takes `unknown`.
- `enumeration.ts` reached `@bluejay/utils`' `{}`-constrained parameter
  through `as {}`; `as object` satisfies it without the banned type.

**Changed on purpose — part of the major**
- `isJSONSchemaLike(obj: {})` → `obj: unknown`. Strictly **widening**: every
  call that compiled before still compiles, and `unknown` values no longer
  need a cast at the call site. `isPlainObject` is not declared as a type
  guard in `@types/lodash`, so the narrowing is reasserted internally.
- `enumeration()` declared its return type as `any`. It returns exactly what
  `any()` returns, so it is now `TJSONSchema`. This is a compile-time break
  for callers that were relying on `any` to skip narrowing — the same call
  `thrown` made, and the reason a major is right regardless of `engines`.

**Kept, with the reason written down at the site**
Four `any`s are load-bearing and each carries a scoped `biome-ignore` with
its justification: `TJSONSchema`'s `[key: string]: any` index signature (the
thing that makes the type open to the whole of JSON Schema, and what every
`TJSONSchema & { ... }` intersection in `src/types` depends on), `default`
and `enum` (arbitrary JSON by definition, and inconsistent to narrow while
the index signature stays), and `TObjectJSONSchema`'s `= any` default
(narrowing it tightens `required` and `properties` on the type three of the
utils take as a parameter). `object<T extends {}>`'s constraint is kept for
a concrete reason too: `object` and `Record<string, unknown>` both fail
`TObjectJSONSchema`'s own constraint, and the latter would reject any
consumer passing an interface, which never gets an implicit index signature.

Gates: `biome check .` clean, `tsc --noEmit` clean, coverage back to **100%
across all four metrics** (the `isJSONSchemaLike` rewrite opened an early
return, closed with seven cases covering non-plain-object inputs — arrays,
`null`, `undefined`, primitives and a class instance).

Every source change the linter prompted was **differential-tested, not
argued**: the same 105-scenario harness re-run against the published 1.0.0
over both new entry points — **212 comparisons, 0 mismatches**, export names
still identical. es-check confirms both bundles are still es2015.
The README follows the scope's shape — title → badges → description →
Requirements → Installation → Usage → API → License — and documents all 30
exports rather than the four the old one showed. `LICENSE` is added (the
package was MIT in `package.json` with no licence text shipped) and 11
`keywords` fill a field that was empty.

Every example was **executed**, not written from reading the source: 65
assertions against the packed tarball, typechecked first under `strict` +
`nodenext` on TypeScript 7 with no casts on any documented call. That
turned up five things, three in the inherited docs and two in prose written
for this commit:

- The old "JSON schema compatibility" example **did not compile**. `type`
  and `format` are the `JSONSchemaType`/`JSONStringFormat` enums, so the
  bare `type: 'object'` string it showed is rejected. The example now uses
  the enum members, with a note that a schema literal copied out of a
  `.json` file needs them or an assertion.
- The ajv snippet was wrong twice over: `new Ajv()` off a default import is
  not constructable under `nodenext` (it is `import { Ajv } from 'ajv'`),
  and the schema it compiled used `email()`, which **throws** `unknown
  format` on a bare ajv 8. Both corrected, with the `ajv-formats`
  requirement called out.
- I claimed ajv's strict mode rejects `nullable: true` schemas. It does not
  — `['string', 'null']` compiles fine. `allowUnionTypes` is only needed for
  a union of two non-`null` types, which is what `enumeration()` over mixed
  values produces.
- I claimed every factory mutates its options argument. Nine do — `date`,
  `dateTime`, `email`, `uuid`, `array`, `list`, `tuple`, `object`,
  `enumeration` — and `any` returns the argument itself, but `string`,
  `number`, `integer` and `boolean` do not, because `cleanOptions` copies.
  The caveat now names them; each of the 14 is asserted.

One source fix rode along, found by trying to run the docs.
`cleanOptions(options: TCommonOptions, ...)` typed its first parameter as
`{ nullable?: boolean }`, so `cleanOptions({ nullable: true, title: 'T' })`
— precisely what the function exists to do — failed excess property
checking and would not compile. Widened to `TOptions<TJSONSchema>`, which is
what every internal caller already passes. Strictly widening: every call
that compiled against the old signature still does. Three tests cover it,
the first doubling as the compile-time guard.

Gates: 74 tests, 100% coverage on all four metrics, `biome check` and
`tsc --noEmit` clean.
Add `.github/workflows/ci.yml` and `.github/workflows/publish.yml` as thin
callers of `tselect-npm/.github`, both pinned `@v1`. They are the shared
templates from `tselect-npm/.github`'s `templates/workflows/`, rendered
verbatim — that repo's `tselect` CLI owns these two files and rewrites them
on every sync, so anything hand-added here would be silently deleted the
next time `tselect sync schema --apply` runs. Repo-specific prose belongs in
`templates/workflows/schema/`, and nothing here earns that: the shared
`ci.yml` needs no inputs (every default fits, including `es-check-target:
es2015`, this package's declared ceiling) and the shared `publish.yml`
already describes the @bluejay tag problem generically.

`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.

What is specific to this repo is recorded in CHECKLIST.md rather than in a
workflow comment: it carries **19 @bluejay-era tags** — an unprefixed
`1.0.0`, `v1.1.0` through `v1.9.5`, and `v3.0.0-beta.0` — and **none of them
is `v1.0.0`**, so nothing names the commit the registry says
`@tselect/schema@1.0.0` shipped from (`64abede`). That is the same failure
the other repos have, arriving from a third direction: `url` and friends had
a `v2.0.0` squatting on the tag the major wants, `thrown` had no tags at
all, and this one has plenty of tags and not the one that matters. The plan
job would count every commit in the history. Clearing them is a step 9
prerequisite and publishing is deferred, so it is recorded rather than done
— `since-ref` is the documented escape hatch until it is.

Also declares `engines.node: ">=22"` — the bottom of the CI matrix, so the
floor is proven by construction rather than asserted. This is on its own
what makes the release a major.

Gate: the whole pipeline run locally against a clean copy of the tracked
tree, in CI's order and with CI's pinned tool versions —
`pnpm install --frozen-lockfile`, `typecheck`, `lint`, `cov` (74 tests,
100% on all four metrics, `coverage/lcov.info` written), `audit` (0), then
the `build + package` job's outcome assertions rather than its exit code:
all four dist files non-empty, `es-check@9.6.4 es2015` clean on both
bundles, and `attw@0.18.5 --profile node16` reporting no problems on the
packed tarball. The workflows themselves are unverified until the branch is
pushed and Actions runs them.
@SylvainEstevez
SylvainEstevez merged commit 6c9e537 into main Aug 30, 2026
9 checks passed
@SylvainEstevez
SylvainEstevez deleted the chore/modernize branch August 30, 2026 13:47
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