diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fe72d2f..e2d5702 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -119,6 +119,35 @@ jobs: name: dist path: dist/ + # Consumer typecheck against the minimum supported TypeScript (README + # § Requirements), run over `dist/`'s emitted declarations and the whole + # suite. Deliberately a separate job, not a step in `build`: the compiler is + # a different major picked by `npx`, and it must never enter + # `devDependencies`, the local `check`/`verify` tiers, or the lockfile. See + # development/ci.md § TypeScript compatibility. + compat: + needs: build + runs-on: ubuntu-latest + # Same baked image as `build` — without it this job re-downloads Node + # per run (see docker/Dockerfile). + container: + image: gitea.e1nsnull.de/tmu/act-ci:26.8.2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: "npm" + - run: npm ci + # Reuse the exact `dist/` that `check`, `test:ci` and `publint` were + # run against, so the compat gate judges the shipped artifact and + # pays no rebuild. + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - run: npm run test:compat + # Advisory scans (dead code, dependency freshness). Non-blocking: surfaced in # the Actions tab for visibility, but must never gate a merge — so # continue-on-error and intentionally NOT in `publish`'s `needs`. @@ -142,7 +171,9 @@ jobs: publish: if: startsWith(gitea.ref, 'refs/tags/') - needs: build + # `compat` gates the release: an artifact that is not consumable at the + # claimed TypeScript floor must never ship. + needs: [build, compat] runs-on: ubuntu-latest # Same baked image as `build` — setup-node still owns the registry-url # `.npmrc` rewrite here; only the Node download is skipped. diff --git a/CHANGELOG.md b/CHANGELOG.md index 36bc10c..55bdc99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- add a CI `compat` job that type-checks the suite and a consumer fixture + against the minimum supported TypeScript (5.9), consuming the built `dist/` + and gating `publish` +- correct the documented consumer floor to TypeScript >= 5.9 and drop the + unsupported `node10` resolution claim + ## [0.8.3] - 2026-09-28 - upgrade dependencies: oxfmt 0.71 (the only range widened), oxlint 1.86, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3afe51d..8a21e1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,9 @@ the rules so agents and humans don't diverge. - **Build:** `npm run build` - **Test:** `npm run test`, `npm run test:ci` +- **Compat (CI-only):** `npm run test:compat` — type-check the suite + a consumer + fixture against the minimum supported TypeScript; needs a built `dist/` and + network access (`npx`) - **Watch:** `npm run watch` - re-runs tests on file save, humans only - **Checks:** `npm run check`, `npm run fix` - **Doc tests:** `npm run create:doc-tests` — compile the `ts`-tagged fences @@ -44,6 +47,7 @@ faster tiers catch less, slower tiers are more thorough": | `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s | | `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s | | CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ | +| CI compat (auto) | on push to `main` / tag | `compat` job — `test:compat` over the built `dist/`; gates `publish` — see [compat/](./compat) | ~15s | | CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s | @@ -142,7 +146,9 @@ CI step. Pick the prefix that matches the script's lifecycle: unit tests); `test:unit` skips the typecheck for fast local iteration; `test:coverage` runs c8 over the hand-written tests only; `test:doc` runs the generated doc examples without coverage; `test:ci` chains the two and fails - below 100% coverage on `src/` (CI-only; `verify` stays coverage-free). + below 100% coverage on `src/`; `test:compat` type-checks the suite + a + consumer fixture against the minimum supported TypeScript via `npx` (both + CI-only; `verify` stays coverage- and network-free). - `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`. - `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project diff --git a/README.md b/README.md index 4fdd146..e401f61 100644 --- a/README.md +++ b/README.md @@ -54,9 +54,14 @@ npm install tiny-pattern-ts ## Requirements - **Node.js >= 26** (`engines` field; pinned via `.node-version`). -- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts` - use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; - both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. +- **TypeScript >= 5.9** to consume the published declarations. The floor is set + by the `type-fest` types the declarations use and is checked in CI against a + consumer fixture; see [`compat/`](./compat) and + [development/ci.md § TypeScript compatibility](./development/ci.md#typescript-compatibility). + The emitted `.d.ts` keep their relative `.ts` specifiers, which resolve under + `node16` / `nodenext` / `bundler`. The package exposes only an `exports` map + (no `main` / top-level `types`), so the legacy `node10` resolver does not + apply. - The package is **ESM-only** (no CommonJS shim). ## Examples diff --git a/compat/fixture.ts b/compat/fixture.ts new file mode 100644 index 0000000..b15f1b0 --- /dev/null +++ b/compat/fixture.ts @@ -0,0 +1,77 @@ +// Consumer smoke test for the minimum supported TypeScript (see README +// § Requirements). It imports the package by name, so it resolves through the +// `exports` map to the emitted `dist/*.d.ts` — including the relative `.ts` +// specifiers they keep — rather than to the source. Run by `npm run test:compat` +// and the CI `compat` job only; never by `check` / `verify`. +// +// Why the `expectTypeOf` assertions: a compile that merely succeeds is a weak +// oracle. An `any`-typed declaration would compile, but `expect-type`'s exact +// equality and `.not.toBeAny()` reject `any`, so the assertions prove the +// emitted types are real. See development/ci.md § TypeScript compatibility. + +import { expectTypeOf } from "expect-type"; +import { + getPrimitiveUnionMatcher, + getPrimitiveUnionMatcherW, + getTaggedUnionMatcher, + getTaggedUnionMatcherW, +} from "tiny-pattern-ts"; + +type ResultCode = "ok" | "created"; + +const toStatus = getPrimitiveUnionMatcher()({ + ok: () => "OK", + created: () => "CREATED", +}); + +expectTypeOf(toStatus).not.toBeAny(); +expectTypeOf(toStatus).toEqualTypeOf<(shape: ResultCode) => string>(); + +// The widening twin keeps each handler's own return type in the union. +const toStatusW = getPrimitiveUnionMatcherW()( + { ok: () => "OK" as const }, + (rest) => { + expectTypeOf(rest).not.toBeAny(); + expectTypeOf(rest).toEqualTypeOf<"created">(); + return "CREATED" as const; + }, +); + +expectTypeOf(toStatusW).not.toBeAny(); +expectTypeOf(toStatusW).toEqualTypeOf< + (shape: ResultCode) => "OK" | "CREATED" +>(); + +type Contact = + | { kind: "email"; address: string } + | { kind: "phone"; number: string }; + +const format = getTaggedUnionMatcher()("kind")({ + email: (e) => { + expectTypeOf(e).not.toBeAny(); + expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>(); + return e.address; + }, + phone: (p) => { + expectTypeOf(p).not.toBeAny(); + expectTypeOf(p).toEqualTypeOf<{ kind: "phone"; number: string }>(); + return p.number; + }, +}); + +expectTypeOf(format).not.toBeAny(); +expectTypeOf(format).toEqualTypeOf<(shape: Contact) => string>(); + +const formatW = getTaggedUnionMatcherW()("kind")( + { email: (e) => e.address }, + (rest) => { + expectTypeOf(rest).not.toBeAny(); + expectTypeOf(rest).toEqualTypeOf<{ kind: "phone"; number: string }>(); + return rest.number; + }, +); + +expectTypeOf(formatW).not.toBeAny(); +expectTypeOf(formatW).toEqualTypeOf<(shape: Contact) => string>(); + +export { format, formatW, toStatus, toStatusW }; diff --git a/compat/tsconfig.json b/compat/tsconfig.json new file mode 100644 index 0000000..c70b656 --- /dev/null +++ b/compat/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/strictest/tsconfig.json", + "compilerOptions": { + "lib": ["es2024"], + "module": "nodenext", + "target": "es2024", + "types": ["node"], + "skipLibCheck": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["../src", "../scripts", "*.ts"], + "exclude": ["../src/doc-test"] +} diff --git a/development/ci.md b/development/ci.md index 5ff08a8..fc2a3f3 100644 --- a/development/ci.md +++ b/development/ci.md @@ -6,6 +6,9 @@ the job graph; this file records why it is shaped the way it is. ## Pipeline - **`build`** (push to `main` / tag) — build + correctness + packaging. +- **`compat`** (push to `main` / tag) — type-check the suite and a consumer + fixture against the minimum supported TypeScript; consumes `build`'s `dist/` + and gates `publish`. See [§ TypeScript compatibility](#typescript-compatibility). - **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports, never fails the build. - **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`, @@ -130,6 +133,72 @@ the `build` job; `npm run verify` stays coverage-free. lists it under `--all`. It carries a file-level `/* c8 ignore start */` with the reason. Adding runtime code there means removing that directive. +## TypeScript compatibility + +#### Decision (2026-09) + +A dedicated `compat` job runs `npm run test:compat` — the `npx`-pinned +TypeScript 5.9 compiler (`typescript@5.9.2`) over `compat/tsconfig.json` — +against the `dist/` artifact `build` produced, and `publish` requires it. The +floor is TypeScript 5.9, pinned in the `test:compat` script itself (the single +source of truth) and documented in [README § Requirements](../README.md#requirements). + +#### Why + +- The compiler is a **different major** from the repo's TypeScript 7, so it is + resolved by `npx` at run time. It must never appear in `devDependencies`: that + would install it for every local `npm ci` and drift the lockfile, which would + put a second compiler in the local `check` / `verify` loop and every editor. +- **CI-only is the point.** `npx` fetches over the network — like `maintain`'s + scans, a network-bound check is never a local feedback tier (see + [workflow.md § Feedback tiers](./workflow.md#feedback-tiers)). Locally the + same gate is reproducible with `npm run build && npm run test:compat`. +- **`compat` consumes `build`'s artifact** rather than rebuilding, so it judges + the exact bytes `check`, `test:ci` and `publint` saw. +- **`compat` gates `publish`** because the types are the feature: an artifact + that is not consumable at the advertised floor must not ship. +- **The fixture is a consumer, not a unit test.** `compat/fixture.ts` imports + the package by name (`tiny-pattern-ts`), so it resolves through the `exports` + map to `dist/index.d.ts` and exercises the emitted declarations' relative + `.ts` specifiers — not the source. `expectTypeOf` / `.not.toBeAny()` are + load-bearing: a bare compile would also pass if a declaration collapsed to + `any`; the exact-equality assertions reject that. + +#### Rejected + +- **A `devDependencies` alias** (`npm:typescript@5.9`): installs the legacy + compiler locally, defeating "CI only". +- **A second lockfile / sub-project** (`compat/` with its own `npm ci`): a + pinned, reproducible matrix, but a whole extra lockfile to maintain for one + compiler. `npx -p` is enough. +- **A `paths` / `moduleSuffixes` redirect** to typecheck the _existing_ suite + against `dist/` without touching it: `paths` cannot remap the relative + `./index.ts` imports the tests use; `moduleSuffixes` only lets _missing_ + source resolve to suffixed copies, so it would need generated `.compat.ts` + declarations staged into `src/` (plus excludes). Both spend more than the + fixture buys. See the [handover](../backlog.tasks) discussion. +- **Writing the fixture against source** (relative import): it would prove the + source compiles under 5.9, not that the _published_ declarations do, which is + the promise consumers rely on. +- **Replacing `attw`**: `attw` owns the full resolution matrix + (`node10`/`node16`/`nodenext`/`bundler`); `compat` answers only "does the + documented floor compile the artifact". + +#### Known issue + +- `@tsconfig/node26` cannot be extended: its `lib: ["es2025", ...]` and + `target: es2025` are rejected by 5.9 (`TS6046`). `compat/tsconfig.json` + extends only `@tsconfig/strictest` and sets `lib` / `target: es2024`, the + ceiling 5.9 accepts. +- `skipLibCheck: false` is deliberate — it is what makes the floor honest + (`type-fest` pins it at 5.9), rather than hiding a broken dependency d.ts + behind `true`. +- The version appears in both the `test:compat` script and the README; a floor + bump is a two-file change. The script is authoritative. +- `src/doc-test` is excluded from `compat/tsconfig.json`. The generated examples + are checked against the source by their own project; `test:compat` covers the + suite plus the fixture. + ## Coverage serving #### Decision (2026-09) diff --git a/development/library.md b/development/library.md index 909db0e..b4484da 100644 --- a/development/library.md +++ b/development/library.md @@ -107,8 +107,8 @@ Each factory is two overloads whose order is load-bearing: - **Variance / `const` type parameters / `NoInfer` / `unique symbol` brands / defaulted type-param guards.** None change inference or evaluation order; `in`/`out` on the handler map broke contextual typing outright. `NoInfer` - specifically leaks into the emitted `.d.ts`, raising the consumer floor to - TypeScript 5.4 (README promises `>= 5.0`). + specifically leaks into the emitted `.d.ts`, which would raise the consumer + floor above the documented one (see [README § Requirements](../README.md#requirements)). - **Union merge**, **overload merge with only the exhaustive arm last**, **inferred universe**, **conditional `RequireKeys`**, **cases-first curried** — decided against while the API was single-object; their reasons (reported diff --git a/knip.json b/knip.json index 19b899b..f6fa77a 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "./node_modules/knip/schema.json", - "entry": ["scripts/*.ts"], + "entry": ["scripts/*.ts", "compat/*.ts"], "ignoreDependencies": ["@runwisp/pubv"] } diff --git a/package.json b/package.json index 157110d..8c0859d 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "maintain:outdated": "check-outdated --ignore-pre-releases --ignore-packages @types/node", "test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"", "test:ci": "npm run test:coverage && npm run test:doc", + "test:compat": "npx --yes --package typescript@5.9.2 tsc --project compat/tsconfig.json", "test:coverage": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types $(git ls-files 'src/*.test.ts')", "test:doc": "node --test --strip-types \"src/doc-test/__generated__/*.test.ts\"", "test:unit": "node --test --strip-types \"src/**/*.test.ts\"",