Repository navigation
💥 Modernize toolchain: pnpm, tsdown, Vitest, Biome, TypeScript 7, CI - #29
Merged
Merged
Conversation
- 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.
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.
Step 0–8 of
CHECKLIST.mdfor@tselect/schema. Fourth repo afterurl,thrownandhttp-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:Why this is a major, four times over
engines.nodebecomes>=22— the bottom of the CI matrix, so the floor is proven by construction rather than asserted.exportsmap closes deep imports.gh search code "@tselect/schema/dist"found no consumer doing it, but it is a break.enumeration()'s return type moves fromanytoTJSONSchema— a compile-time break for callers relying onanyto skip narrowing. Same callthrownmade.ajvleavesdependencies, closing a transitive install for anyone resolving it through this package rather than declaring it.The scheduling premise was wrong: ajv is test-only
schemawas ordered second-to-last because ofajv@6, on the theory that 6 → 8 was a breaking change this package owed its consumers.src/never imports ajv. The only references aretest/factories/object.test.tsandtest/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, socjs-module-lexercannot detect them and the ESM namespace comes back empty. BecausemakeSchema→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 inhttp-method. Fixed with the default import, which Node's CJS interop always populates.Checked non-vacuous: restoring the namespace form makes
dist/index.mjsthrowLodash.omit is not a functiononstring()whiledist/index.cjsstill returns{"type":"string"}.lodash is kept rather than vendored — nine helpers including
cloneDeepandmergeWith, 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 wasTCommonOptions, i.e.{ nullable?: boolean }, socleanOptions({ nullable: true, title: 'T' })— exactly what the function exists to do — failed excess property checking and would not compile. Widened toTOptions<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, andunknownvalues no longer need a cast at the call site.One inconsistency pinned rather than fixed
nullable(enumSchema, false)strips'null'fromtypebut still appendsnulltoenum, 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, becausetsdown.config.tshad been written but nevergit added — and the build still exited0.strict+nodenexton TS 7 with no casts on any documented callpnpm auditaudit fixbiome check·tsc --noEmitattw --profile node16es-check es2015The published tarball was shipping a stray 30 kB
.log32156andtslint.json;.npmignoreis 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-linebiome-ignoreand the reason written at the site —TJSONSchema's[key: string]: anyindex signature is what makes the type open to the whole of JSON Schema, and underunknowneveryschema.minLengthread would need narrowing. Nothing is disabled inbiome.json.Still open after this merges
v1.0.0— an unprefixed1.0.0,v1.1.0throughv1.9.5, andv3.0.0-beta.0. Nothing names64abede, the commit the registry records as1.0.0'sgitHead, so the release plan job would count every commit in the history. A third variant of the same problem:urlhad a squatter on the tag its major wanted,thrownhad no tags at all. Clearing it is a step 9 prerequisite;since-refis the escape hatch until then.actionslanguage cannot be enabled until workflows exist on the default branch — it will pick up on the nexttselect syncafter this merges.