314 Commits
Author SHA1 Message Date
tmu 3b58b06081 🚀 Release 0.9.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 38s
CI / compat (push) Successful in 26s
CI / maintain (push) Failing after 14s
CI / publish (push) Successful in 20s
2026-09-29 21:37:53 +00:00
tmu 36e317bf17 🔀 Merge feature/ts-compat-ci into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 29s
CI / maintain (push) Failing after 13s
CI / compat (push) Successful in 25s
CI / publish (push) Skipped
2026-09-29 21:24:16 +00:00
tmu 0e56fff59e 📝 Check off the CI TypeScript-floor task
The backlog item asked for a 5.0 baseline; the floor is 5.9 (set by type-fest
and the library's own gate), so the text now says "the TypeScript floor".
2026-09-29 21:24:05 +00:00
tmu 24cc007ab0 🔥 Drop redundant not.toBeAny assertions in the fixture
The exact-equality `toEqualTypeOf` assertions already reject `any`, so the
`.not.toBeAny()` guards were redundant. Refresh the fixture header and
development/ci.md to match.
2026-09-29 21:23:15 +00:00
tmu ebe58a9167 📝 Say compat is CI-only and run by hand locally
The pipeline bullet's "(push to `main` / tag)" read like a local push hook.
State plainly that `compat` has no local tier — it is not in `check`, `verify`,
`test:ci` or any hook — and is run manually with `build && test:compat`.
2026-09-29 21:15:01 +00:00
tmu 00ed6bee1e ✅ Assert handler params in the compat fixture
Give the primitive-union handlers a parameter and assert its member type, and
assert the tagged-union widening handler's parameter too. A matcher without
handler-parameter assertions cannot catch a wrong or `any` handler parameter:
the matcher's own `(shape: T) => R` type is built from `T` and the handler
return, so it stays correct even when `Member<T, K>` inversion is broken.
2026-09-29 21:10:15 +00:00
tmu 75807c4bd8 👷 Type-check the TS floor in CI
Add a `compat` job that type-checks the whole suite and a consumer fixture
against the minimum supported TypeScript (5.9), reusing the `dist/` artifact
`build` produced and gating `publish`. The compiler is resolved by npx, so it
never enters `devDependencies` or the local `check`/`verify` loop.

The fixture imports the package by name, resolving the emitted declarations
through the `exports` map; `expectTypeOf` / `.not.toBeAny()` make it reject an
`any`-typed declaration, which a bare compile would accept.

Correct the README consumer floor from >= 5.0 to >= 5.9 (set by `type-fest`)
and drop the `node10` resolution claim, which the exports-only entry never
satisfied.
2026-09-29 20:51:41 +00:00
tmu 45df45df4b 🚀 Release 0.8.3
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 36s
CI / maintain (push) Successful in 16s
CI / publish (push) Failing after 18s
2026-09-28 22:02:16 +00:00
tmu 21b628f91d 🔀 Merge chore/prune-backlog-and-upgrade-deps into main 2026-09-28 21:59:57 +00:00
tmu 3c802ad7df ⬆️ Bump the pi-lsp pin to 0.0.47
The agent-side LSP extension is declared in `.pi/settings.json`, not in
`package.json`, so neither `npm update` nor `maintain:outdated` sees it.
Bump it with `pi install npm:@spences10/pi-lsp@0.0.47 -l`, which
rewrites the pin and refreshes the gitignored `.pi/npm/` install cache,
then reformat the file: pi writes it back in its own two-space style,
which `check:oxfmt` rejects.

0.0.47 bounds the project-binary trust prompt — it follows tool
cancellation and times out after 30 s, returning a tool error instead of
leaving the session stuck in `Working`, and an allow-once decision now
survives an idle language-server restart. The read-only usage and the
`tsc --lsp` wiring are unchanged, so `development/tooling.md` only moves
the recorded version. No changelog note: the pin is agent tooling, not
shipped code, as when it was adopted (863198d).

The running pi session still has 0.0.46 loaded; restart pi to pick up
the new version.
2026-09-28 21:58:11 +00:00
tmu 5025fa3870 🔧 Drop the redundant knip entry pattern
knip derives the public entry from package.json `exports` and resolves
it to src/index.ts itself, so naming the file in `entry` is redundant:
maintain:knip reported it as a configuration hint, and the scan is now
silent. Verified the barrel is still treated as the entry — the scope
is clean with and without dist/, and with src/index.test.ts removed.

Supersedes the workaround recorded in development/tooling.md, which
silenced an unused-file report for the barrel that the current entry
derivation no longer produces. Rewrite the section to state the
behaviour and keep the rejected `paths` mapping.
2026-09-28 21:55:05 +00:00
tmu eb1acbd9d8 ⬆️ Upgrade dependencies
Bump every direct dependency to the latest registry version: cspell
10.3.5, oxfmt 0.71.0, oxlint 1.86.0, oxlint-tsgolint 7.0.2003,
@types/node 26.6.3, the LSP protocol types 3.18.4 and 85 transitive
entries. oxfmt is the only range widened (^0.70.0 -> ^0.71.0), because
0.x minors are breaking under semver and `npm update` stops at the
range; the rest moved within their existing ranges.

`npm run maintain:outdated` is clean and `npm run verify` is green with
no rule or format fallout. `maintain:knip` reports one configuration
hint — the redundant `src/index.ts` entry pattern in knip.json — but
knip did not move, so the hint predates this bump and stays advisory.
2026-09-28 21:47:05 +00:00
tmu df09d3ae61 📝 Prune the completed backlog items
Remove every done/cancelled item. Git history is the archive and each
item's rationale already lives in the README, in development/ or in a
released changelog entry; none was recorded only here, so no item had to
be rescued into development/ before it could go.

- drop the `v1.0:` and `Matcher:` projects too: their last open task is
  done, so those headers would stand empty
- summarise the change under `[Unreleased]` in the changelog
2026-09-27 20:41:59 +00:00
tmu ac1fd60043 🚀 Release 0.8.2
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 37s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 15s
2026-09-25 23:20:02 +00:00
tmu f5871c37c8 🔀 Merge chore/readme-comparison into main 2026-09-25 23:17:33 +00:00
tmu 7a1eaa0d7f 📝 Add an alternatives section
Add a short README section under Alternatives, one heading per
alternative (ts-pattern, Effect.Match, match-iz and plain switch), each
saying what it is and what tiny-pattern-ts is or does differently. State
the data-last, pipe-friendly shape in Description.

- measure the footprint: ~0.2 kB minified + gzipped, not the
  unminified ~1.8 kB; ts-pattern is ~2 kB
- correct match-iz: it ships types, it just cannot prove exhaustiveness
- drop switchcase / ts-match (unmaintained)
- check off the backlog task

Resolves: backlog "Add comparison section vs. other TS pattern-matching libs"
2026-09-25 23:16:14 +00:00
tmu 09011d838f 🔀 Merge chore/cleanup-readme into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-24 21:56:33 +00:00
tmu 8261759190 📝 Restore and rewrite the README entry point
The README collapsed to API-only prose once the old match/P surface was
dropped. Rebuild its entry-point shape: a quick-start Synopsis walked through
a Contact union, a dedicated Installation section, and a real-world Examples
section (primitive-union dispatch, a fallback, a property union narrowed by
the tagged-union matcher, and the widening variant).

Set the tagline and Description to the library's identity — exhaustive,
type-safe pattern matching for TypeScript — and state the goal Moose-style:
type-safe pattern matching with a lean syntax, accomplished by exhaustive
branches and typed per-branch handler parameters, backed by autocomplete and
a tiny footprint. Drop the F#-style framing and the stale "matches method is
a type guard" and "(not regex)" copy from the README, AGENTS.md and the
package.json description. Every fence is a doc-test, so the examples cannot
drift from the API.
2026-09-24 21:55:51 +00:00
tmu 28d87b3387 📝 Close out migration guide and backlog tasks 2026-09-24 20:51:42 +00:00
tmu ccbd0a297c 🔀 Merge feature/doc-tests into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 29s
CI / publish (push) Skipped
CI / maintain (push) Successful in 15s
2026-09-24 13:51:41 +00:00
tmu 400fe902e0 ♻️ Run doc-test generation as an explicit CI step
Drop the `pretest:ci` lifecycle hook and call `create:doc-tests` from the
`build` job before `test:ci`. The hook hid the step from the job log and
made `test:ci` behave differently under npm than when run directly.
2026-09-24 13:08:45 +00:00
tmu 4dc58395c0 ♻️ Run doc tests outside the coverage gate
An example that exercises a line no hand-written test reaches would let
the `--100` gate pass on documentation alone. Node has no file-level
exclusion, and c8's `--exclude` only filters the report, so run the two
in separate processes: `test:coverage` runs c8 over the tracked tests
only (`git ls-files`, which skips the untracked generated files), and
`test:doc` runs the generated examples without c8. `test:ci` chains
them.
2026-09-24 12:52:42 +00:00
tmu a9b588e1c3 ♻️ Drop assert imports from doc fences
`assert` is the sole injected exception: the generated test always binds
it, so a fence must not import it. Revert the README imports and the
generator's node:assert merging; the fences keep the assigned variables
and `assert.equal` calls, and the generator again rejects a fence that
imports node:assert.
2026-09-24 12:17:24 +00:00
tmu d0dd8641f2 ✅ Assert README example results
Assign every documented matcher result to a variable and assert it with
`assert.equal`, so the compiled README doc-tests verify behavior instead
of merely running the code. The examples import `node:assert` themselves
to stay copy-pasteable; the generator now merges that import into its
prelude assert rather than rejecting it (only `node:test` remains
reserved).
2026-09-24 12:13:36 +00:00
tmu 30d97a9209 ✨ Compile doc code fences into tests
Every `ts`-tagged fence in README.md / CONTRIBUTING.md now becomes an
executed `node:test` case in a gitignored generated file, so a
documented example cannot drift from the API. The generator hoists and
merges the leading imports, rewrites the library specifier to the
`#test-tiny-pattern-ts` alias, and rejects a fence with no describing
paragraph or one that re-imports a prelude module.

CI regenerates via `pretest:ci`; `create:doc-tests` runs the generator,
formats, then typechecks the output against a scoped tsconfig that
relaxes `noUnusedLocals`. c8's default excludes already omit the
generated `*.test.ts`, so `test:ci` is unchanged.
2026-09-24 11:53:29 +00:00
tmu d1963b0329 🔀 Merge feature/api-surface into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 26s
CI / publish (push) Skipped
CI / maintain (push) Successful in 15s
2026-09-23 22:51:21 +00:00
tmu 0bde38d6e4 📝 Summarize the API docs work in the changelog 2026-09-23 22:51:11 +00:00
tmu 38484aa16d 📝 Refresh license year, backlog notes and JSDoc
Bump the copyright to 2026 in LICENSE and README, add the
comparison-section notes to the backlog, and trim the primitive-union
factory JSDoc now that the universe rules live in README § Caveats.
2026-09-23 22:50:05 +00:00
tmu c878e60ff2 📝 Bind the handler-map function in every example
Each README and JSDoc example now assigns the function that takes the
handler object (the factory result, or the tagged key step) to a match…
variable and reuses it, so the builder is created once instead of per
call.
2026-09-23 22:33:43 +00:00
tmu c8bee95088 📝 Document when to use each matcher
Add the universe/return axes and a "Use when" column to the README API
table, and the same one-liner to each of the four factory JSDoc blocks.
2026-09-23 22:25:39 +00:00
tmu 5ddbbdc183 ♻️ Keep the public API to the four factories
The builder types are already the inferred return types and travel into
the emitted .d.ts, so exporting them only made them nameable while
pinning the internal Strict/Widening split as API. Revert the type
exports and the public renames, drop the type-surface test, and document
only the factories in README and development/library.md.
2026-09-23 22:19:02 +00:00
tmu 9c9468fa3c 📝 Explain the W widening suffix in the API section
State that the `W` suffix means widening and what that widens: the
matcher's return value goes from one common `R` to the union of every
handler's return type.
2026-09-23 22:11:22 +00:00
tmu f485b1bae2 ✨ Finalize and document the public API surface
Export the matcher builder types from the barrel and rename them off the
internal Strict/Widening suffixes, so the type a factory returns is
nameable: PrimitiveUnionMatcher/W, TaggedUnionMatcher/W, and the
tagged-union factory types. Add TSDoc to every public symbol — the
declarations carry it into the published package — and pin the exported
names in src/index.test.ts.

Document the API in README and record the surface decision (and the
rejected matcher-shared export) in development/library.md.
2026-09-23 21:39:15 +00:00
tmu acf9d06ddd 🚀 Release 0.8.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 35s
CI / maintain (push) Successful in 14s
CI / publish (push) Failing after 16s
2026-09-23 21:24:42 +00:00
tmu eba60f6569 🔀 Merge chore/coverage-threshold into main 2026-09-23 21:23:37 +00:00
tmu 52ce655d8e 👷 Gate CI at 100% coverage
test:ci now runs c8 with --all --include "src/**/*.ts" --100, so the
build job fails when any runtime file under src/ is untested. --all is
what makes the gate non-vacuous: without it c8 counts only the files the
suite happened to load, and a new untested module stays invisible.

Add src/index.test.ts to load the public barrel, which was previously
never imported at runtime and so read as 0% under --all. matcher-shared.ts
is types-only (an empty runtime image) and carries a file-level c8 ignore
with the reason.

Why 100% and the rejected alternatives: development/ci.md § Coverage
threshold.
2026-09-23 21:12:24 +00:00
tmu 76327a8c47 🔀 Merge chore/duplicate-tag-union-test into main 2026-09-23 20:55:56 +00:00
tmu d7004c0f71 📝 Clarify the duplicate-tag known issue 2026-09-23 20:52:45 +00:00
tmu 69231eb9ac 📝 Note the duplicate-tag tests in the changelog 2026-09-23 20:30:44 +00:00
tmu 7ff371c569 ✅ Pin the duplicate-tag union collapse 2026-09-23 20:30:35 +00:00
tmu 0339a493a2 📝 Track the TypeScript 5.0 CI baseline 2026-09-23 20:28:53 +00:00
tmu 2c856f28e3 🔀 Merge chore/open-universe-guidance into main 2026-09-23 20:01:52 +00:00
tmu 4260a4732c 📝 Document why open universes are rejected
Add a README Caveats subsection with user-facing guidance for the two
open-universe shapes: parse external input at the boundary down to a
finite union, or use a runtime Map registry for extensible domains.
Point the finite-universe bullet at it, and point the rejected
fix/open-universe-* entry in development/library.md back at the new
guidance, so rule and decision cross-reference without duplicating each
other.
2026-09-23 19:51:19 +00:00
tmu 11d364e187 🚀 Release 0.8.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 34s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 19s
2026-09-23 17:42:32 +00:00
tmu 8dc0b95320 🔀 Merge fix/union-valued-discriminant into main 2026-09-23 17:37:37 +00:00
tmu 7b90f898d3 ✅ Cover property-union autocomplete and rejection
Add property-union tests for the completion popup, a broad value in the
union, and value/stringification collisions. Rename the existing
property-union tests to a `property union:` prefix so they can be
selected with `--test-name-pattern`, and extend the autocomplete helper
with a `key` option so a probe can complete on a property other than
`kind`.
2026-09-23 16:32:16 +00:00
tmu 1bdf7f0315 🐛 Reject mixed literal and broad universes
`UnsupportedReason` tested `IsLiteral<PatternKey<T>> extends false`.
`IsLiteral` is `boolean` for a union that mixes a literal with a broad
type (`"a" | \`x-${number}\``), so `boolean extends false` is false and
the mix was accepted. Test `extends true` instead, which rejects it.

Found while adding property-union broad-universe coverage; the gate is
shared with the primitive-union matcher.
2026-09-23 16:32:09 +00:00
tmu 55bf6c24a7 🐛 Narrow union-valued and optional discriminants
The tagged-union matcher selected handler parameters with
`Extract<T, Record<K, V>>`, which only keeps a member whose
discriminant is a singleton literal. A property that is itself a
union (`{ color: "red" | "green" | "blue" }`) or optional
(`{ type?: "x" }`) matched no member, so every handler received
`never`, and the fallback saw the whole shape instead of the
unhandled tags.

Replace the selector with `Narrowed`, which distributes over `T`,
drops a member whose `K` cannot take the tag, and narrows `K`. The
`T[K] extends V` fast path keeps an exact member (a discriminated
union's declared interface) untouched. The fallback reuses
`Narrowed` over `Exclude<Tags, HandledTags>`, so a union-valued
property narrows to the unhandled tags rather than the whole shape.

Matching a defined tag on an optional property makes the key
required (`{ type: "x" }`); the `undefined` tag yields
`{ type?: never }` under `exactOptionalPropertyTypes` (absence) or
`{ type?: undefined }` when the property admits an explicit
`undefined`.
2026-09-23 16:04:00 +00:00
tmu e7b5c0284d 🔀 Merge chore/prune-backlog into main 2026-09-23 15:27:05 +00:00
tmu 013bfa883b 📝 Prune the backlog; document the LSP shutdown fix
Remove every done/cancelled backlog item; the reasons they recorded are
already in development/ (library.md, testing.md, tooling.md, workflow.md)
and git history is the archive.

The one exception was the TS 7 LSP shutdown workaround, which lived only in
a code comment. Record it under development/testing.md § Autocomplete
(Known issue) before dropping the bug item.
2026-09-23 15:12:54 +00:00
tmu d99d84d387 🚀 Release 0.7.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 17s
2026-09-23 14:56:59 +00:00
tmu 02f64f804e 🔀 Merge fix/knip-library-entry into main 2026-09-23 14:56:15 +00:00
tmu cbaed8fabc 🐛 Stop knip reporting the source entry
Listing scripts/*.ts in knip's `entry` replaced its default entry
detection, which derives the public entry from package.json `exports`.
Adding the CLI scripts therefore dropped the library entry, so knip
resolved the package through dist/index.js and flagged the unreferenced
src/index.ts as unused. Name the source entry alongside the scripts.

Record the rationale in development/tooling.md.
2026-09-23 14:55:27 +00:00
tmu 410903198e 🔀 Merge chore/upgrade-deps into main 2026-09-23 14:51:49 +00:00
tmu 2e4262383c ⬆️ Upgrade dependencies
Bump every direct dependency to the latest registry version: cspell
10.3.3, knip 6.38.0, oxfmt 0.70.0, oxlint 1.85.0, oxlint-tsgolint
7.0.2002 and type-fest 5.10.0, plus their transitive updates. oxfmt is
the only range widened (^0.68.0 -> ^0.70.0); the rest moved within
their existing semver ranges.

`npm run maintain:outdated` is now clean and `npm run verify` is green
with no rule or format fallout. `maintain:knip` still reports the
pre-existing `src/index.ts` false positive (dist-only `exports`), which
predates this bump.
2026-09-23 14:50:29 +00:00
tmu a8f1a05db2 🚀 Release 0.7.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Failing after 16s
CI / publish (push) Failing after 17s
2026-09-23 14:34:51 +00:00
tmu 675fd0bb54 🔀 Merge feature/finite-universes into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 25s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-23 13:48:11 +00:00
tmu ce287b384f 📝 Note the gate in changelog and backlog
Close the broad-universe type-hole task and cancel the widened-literal
test task, since arbitrary strings no longer fall through to a fallback.
2026-09-23 13:42:25 +00:00
tmu aaeeef1e11 📝 Document the finite-only universe decision
Record why open universes and value/stringification collisions are
rejected, the `Member<T, K>` inversion, and the findings from the
rejected open-universe attempt so they are not re-run.
2026-09-23 13:42:12 +00:00
tmu 3b5269f9e1 🐛 Gate the universe to finite, literal keys
An open universe (`string`, `number`, a template literal) makes the
handler map index-like, so a partial object satisfied the exhaustive
overload and the runtime `dispatch` throw was reachable. A universe
containing a member and its stringification (`"true" | true`, `1 | "1"`)
collapsed two members onto one key.

- reject broad universes and value/stringification collisions at the
  factory, with a diagnostic naming the reason
- invert `PatternKey` against the universe (`Member<T, K>`) instead of
  `PatternParam<K>`, so a standalone `"true"` is typed `"true"`
- intersect `UniverseGate<T>` into the handler and fallback parameters,
  preserving `R` inference and the popup
2026-09-23 13:41:57 +00:00
tmu 1723419e69 🚀 Release 0.6.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 33s
CI / maintain (push) Failing after 13s
CI / publish (push) Failing after 16s
2026-09-22 11:10:34 +00:00
tmu dcd5f8d1c4 🔀 Merge feature/tagged-union-boolean-nullish-tags into main 2026-09-22 11:09:21 +00:00
tmu 4ebe18adb1 ♻️ Align primitive-union matcher names and drop stale lint disables
Rename `MatcherStrict` / `MatcherWidening` to
`PrimitiveUnionMatcherStrict` / `PrimitiveUnionMatcherWidening`, matching
the exported `getPrimitiveUnionMatcher(W)` names and the
`TaggedUnionMatcher*` pair.

Folds in the working-tree lint cleanups: drop the now-unneeded
`typescript/unified-signatures` disables and turn the tagged-union
factory interfaces into type aliases.
2026-09-22 11:05:16 +00:00
tmu 09e1c12270 ♻️ Return dispatch tacitly from the tagged-union factory 2026-09-22 10:57:03 +00:00
tmu 6228b4922d ♻️ Share the Matchable universe between both matchers 2026-09-22 10:54:27 +00:00
tmu d88a4eeb45 📝 Track optional discriminants under the union-property task 2026-09-22 10:45:54 +00:00
tmu fcec637f01 ✅ Check the runtime member in every boolean/nullish handler 2026-09-22 10:36:03 +00:00
tmu f7b7a06f1a ✅ Assert the fallback receives real boolean/nullish tags 2026-09-22 10:33:26 +00:00
tmu c06538d6c1 📝 Check off the tagged-union non-key tags task 2026-09-22 10:23:06 +00:00
tmu e96ca12149 ✨ Allow boolean and nullish tags in tagged unions
A `boolean` / `null` / `undefined` discriminant cannot key a handler
map, so the tagged-union matcher now routes it through the
`PatternKey` / `PatternParam` projection the primitive-union matcher
already used. Both matchers share the projection from
`matcher-shared.ts`; `MapTaggedUnion`, `Handlers`, `HandledMembers`
and `MustBePartial` key off the projected form and invert it to
recover the real member.

Covers exhaustive and fallback dispatch, the redundant-fallback
guard, and the LSP popup offering the tags by name.
2026-09-22 10:22:59 +00:00
tmu b81a8149d9 🔀 Merge chore/rename-primitive-union into main 2026-09-22 09:58:28 +00:00
tmu a88d49ffe2 📝 Check off the primitive-union rename 2026-09-22 09:47:05 +00:00
tmu 02f736507c ♻️ Rename primitive to primitive-union
`getMatcher` / `getMatcherW` become `getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`; `src/primitive.ts` and its test move to
`src/primitive-union.*`. The `Union` suffix mirrors `getTaggedUnionMatcher`.
2026-09-22 09:46:59 +00:00
tmu 0727117afb 🔀 Merge chore/lsp-symbol-search-rule into main 2026-09-22 09:43:56 +00:00
tmu b293e611b2 📝 Prefer LSP for symbol lookup in AGENTS 2026-09-22 09:43:08 +00:00
tmu 63b020338e 🔀 Merge chore/backlog-notes into main 2026-09-22 09:15:04 +00:00
tmu 32b9ce0987 📝 Note two matcher gaps in the backlog
Record the non-exhaustive broad-type hole in the primitive matcher and the
never-typed handler parameters the tagged-union matcher produces when a
property is a union, so both can be picked up as their own work units.
2026-09-22 09:14:58 +00:00
tmu 434c3a2222 🔀 Merge feature/tagged-union-matcher into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 27s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-21 23:29:48 +00:00
tmu 6280061737 📝 Note the primitive-union rename in the backlog 2026-09-21 23:24:06 +00:00
tmu 23a2776d55 📝 Note the widened-string-union matcher test 2026-09-21 23:21:19 +00:00
tmu 937d84ea48 ♻️ Share matcher internals 2026-09-21 23:15:09 +00:00
tmu 6f4f509b69 ⚡ Inline the tag read into dispatch 2026-09-21 23:02:56 +00:00
tmu 3f22e5ca01 🔥 Drop unused unified-signature disables 2026-09-21 22:53:42 +00:00
tmu 78e9af600d ✅ Cover the discriminant-key popup 2026-09-21 22:36:30 +00:00
tmu 8ab3c6dbc6 📝 Document the tagged-union matcher 2026-09-21 22:14:22 +00:00
tmu c0fc090ee4 ✨ Add a matcher for tagged unions 2026-09-21 22:14:10 +00:00
tmu a4b4618c9f 🚀 Release 0.5.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 17s
2026-09-21 13:11:27 +00:00
tmu bf711cf9fc 🔀 Merge chore/test-handler-runtime-arguments into main 2026-09-21 13:09:10 +00:00
tmu 9e03c52473 ✅ Assert the shape each handler receives
A handler's `expectTypeOf(shape)` only proved the type the compiler inferred;
nothing observed the argument `dispatch` passed. Passing `String(shape)` at the
call site therefore kept the whole suite green while breaking every key whose
property name differs from its value (`true`, `null`, `1`).

Pair each handler expectation with a runtime assertion: `assert.equal` where a
handler runs for one shape, the disjunction over the set a `_` fallback accepts
where it runs for several. Where the exact value matters the fallback returns
its shape verbatim and the call site asserts it; a widening pattern absorbs the
remainder type into the return union, so the claim stays strict-free. The
mutation above now fails 10 of the 33 tests.

Rule: CONTRIBUTING.md, rationale: development/testing.md § Handler arguments.
2026-09-21 12:52:58 +00:00
tmu aa6d71076c 🔀 Merge feature/boolean-null-undefined-patterns into main 2026-09-21 11:27:29 +00:00
tmu 80d498e4f4 ⚡ Index dispatch with the raw shape key
String(shape) defeats V8's numeric-key fast path (measured ~2x on
number-keyed dispatch). JS already coerces boolean/null/undefined to the
same property key, so assert shape to string | number and index directly.
The assertion is TS2538-only; the facade keeps it sound. Needs a source
no-unsafe-type-assertion disable, next to the existing one.
2026-09-21 11:23:14 +00:00
tmu 1ad19ba308 📝 Document matcher caveats in the README
State the end-user limits once, in README § Caveats: a member colliding
with its stringification, the unsupported symbol/bigint, and NaN/-0. Drop
the duplicated known-issue prose from development/library.md and the
source comment; link to the README instead.
2026-09-21 10:33:36 +00:00
tmu 16904440cf 📝 Place test-wide suppressions in the config
Drop the fixed no-floating-promises header exception from CONTRIBUTING.md
and AGENTS.md; agents must not add a source disable or edit .oxlintrc.json.
Record the split in tooling.md and testing.md: a one-site false positive is a
source disable, a file-class one lives in the test override.
2026-09-21 10:01:30 +00:00
tmu 9b7dec96e0 🔧 Move test lint disables to oxlint config
The no-floating-promises header was repeated at the top of every test
file. Centralize it, and the no-null suppression the new null/undefined
cases need, in the **/*.test.ts override.
2026-09-21 09:56:39 +00:00
tmu 45495fe2a8 ✨ Match boolean, null and undefined
Extend the matcher universe beyond string | number so a union can carry
boolean, null and undefined members. true|false, null and undefined are
not property keys, so handler keys are their stringification while the
callback still receives the real member. Symbols are rejected (a brand
is compile-time only) and bigint is not a property key.
2026-09-21 09:44:44 +00:00
tmu c32fe08c73 🔀 Merge chore/spike-redundant-fallback into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 16s
2026-09-20 22:08:35 +00:00
tmu f27966e803 ✨ Reject a fallback for an exhaustive map
A fallback paired with a handler map that already covers `T` was still
accepted, even though its remainder is empty. Fold the guard into
`Handled`'s own (F-bounded) constraint so it is checked after inference;
a conditional in the fallback parameter is evaluated while `Handled` is
still its constraint and rejects every partial map whose handler callbacks
need contextual typing.
2026-09-20 22:05:40 +00:00
tmu 7e7f716424 🚀 Release 0.4.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 30s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 16s
2026-09-20 21:46:32 +00:00
tmu 62db7de537 🔀 Merge chore/unify-task-branching into main 2026-09-20 21:44:21 +00:00
tmu ff823b3cba 📝 Route every task through the full branching model
Drop the leaf-task exception in AGENTS.md: a task without subtasks was implemented on the current branch, bypassing create:branch / create:finish. Every task now opens and closes a branch, so the clean-tree / current-main / green-baseline preconditions and the post-merge verify always apply.
2026-09-20 21:43:46 +00:00
tmu eeb831b717 🔀 Merge feature/fallback-remainder into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-19 00:02:23 +00:00
tmu ae7ffb6fa2 📝 Document the two-argument fallback
Rewrite development/library.md around the new shape: the fallback is a second
argument (so it can receive the remainder), `R` needs an inference site in the
handler map, `Exact` restores the excess-property check, and the overload order
is load-bearing. Record this branch's rejected alternatives (single-object `_`,
currying, `this`/HKT, variance/`const`/`NoInfer`/brands) and the redundant-
fallback known issue. Add the changelog note and check off the backlog task.
2026-09-18 23:52:47 +00:00
tmu 0d6a3f1b9b ✅ Cover the dispatch throw
An open universe (`getMatcher<string>()`) types its handler map as an index
signature, so a runtime map with fewer keys still satisfies the exhaustive
overload. Calling the matcher with a missing shape reaches the dispatch guard
and throws — no cast needed.
2026-09-18 23:48:59 +00:00
tmu 8615723c64 ✅ Cover fallback autocompletes
When a fallback argument is present, the second overload supplies the
contextual type, so the handler map popup offers optional keys (`a? b? c?`)
and handled keys stay optional (`b? c?`). The exhaustive one-argument popup
is unchanged. Covered for both factories.
2026-09-18 23:44:51 +00:00
tmu 935e82d205 ✅ Reject a mismatched strict fallback
The factory-contract test now also covers a fallback whose return does not
fit the common return of the handler map — a `number` handler with a
`string` fallback — which `getMatcherW` accepts as `number | string`.
2026-09-18 23:37:44 +00:00
tmu c7223dbe69 🐛 Infer strict return from the handler map
The fallback overload could not infer `R` from the handlers: `R` appeared
only inside the `Exact` constraint, which is not an inference site, so with
inferred handler params it collapsed to `unknown` and the matcher silently
returned `UnaryFn<T, unknown>`. Adding `Partial<Handlers<T, R>>` to the
handler parameter gives `R` an inference site, so the common return is
inferred from the handlers and the fallback alike.

Tests drop their explicit handler return annotations: real handlers are short
and unannotated, and the common type is now inferred from them.
2026-09-18 23:33:34 +00:00
tmu 2d3577716d ✨ Narrow the fallback to unhandled keys
The fallback is now the second factory argument — `(handlers, (s) => …)` —
so its parameter is `Exclude<T, keyof handlers>`. A property's contextual
type is fixed before TypeScript infers its sibling keys, so the remainder is
not expressible while the fallback sits in the handler map; a later argument
is contextually typed from inference on an earlier one.

Both factories take the new shape; the tests and autocomplete probes follow.
The generic overloads guard excess keys with type-fest's `Exact`, so the
error lands on the offending property. A redundant fallback (a full handler
map plus one) is still accepted — the guard for it does not survive inference
and is left to its backlog task.
2026-09-18 23:08:16 +00:00
tmu cc88c07961 🚀 Release 0.3.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 29s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 18s
2026-09-18 07:51:16 +00:00
tmu 62c37e6d89 🔀 Merge chore/protocol-backed-lsp-helper into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-18 07:45:22 +00:00
tmu 947eac8089 ⏪ Revert the LSP notes in testing.md
The protocol cross-link and the TS-internal shutdown-race explanation were
too detailed for the category doc. The helper comment keeps a short,
self-contained note on the stdin-EOF workaround.
2026-09-18 07:43:36 +00:00
tmu eea2fbb17d 🚨 Silence helper oxlint warnings
The completion helper is test-only (excluded from dist/), so the
style-only no-magic-numbers and unicorn/no-null warnings are tolerable
here. A file-level disable keeps them out of check:oxlint without touching
the project config.
2026-09-18 07:38:13 +00:00
tmu b278a153d3 📝 Check off the LSP shutdown bug
Record the cause (Session.updateWatches raced against exit) and the
stdin-EOF fix now that npm run verify is green with no context-canceled
output.
2026-09-18 07:31:10 +00:00
tmu 8b73143740 🐛 Stop the LSP shutdown context-canceled log
The TS 7 Go server races Session.updateWatches against the exit
notification; handleExit returns io.EOF, the background context is
cancelled, and the bare error is flushed to stderr once the outgoing
queue closes (server.go / logger.go). Send shutdown and close stdin
instead: EOF makes the server exit cleanly (code 0, no output), with the
kill kept as a fallback. testing.md records the cause.
2026-09-18 07:31:04 +00:00
tmu 13497d1df8 📝 Check off the LSP helper task
npm run verify is green: 23 tests pass, check:tsc, check:oxlint, check:oxfmt
and check:cspell clean with the protocol-backed helper.
2026-09-18 07:17:19 +00:00
tmu 998c4bfd80 📝 Document the protocol-backed LSP helper
Note that the autocomplete helper drives the server through
vscode-languageserver-protocol, cross-link the tooling decision, and replace
the obsolete string-id known issue with the benign context-canceled stderr
the Go server emits on shutdown.
2026-09-18 07:16:45 +00:00
tmu 9f56d7ae8e ♻️ Back LSP helper with protocol library
Replace the hand-rolled JSON-RPC client (framing, pending map, reply
dispatch) with createMessageConnection and typed requests from
vscode-languageserver-protocol. A catch-all onRequest answers the server
requests the old client replied to, and CompletionList/CompletionItem
replace the ad-hoc shape guards. Public LspSession API is unchanged.
2026-09-18 07:16:25 +00:00
tmu 94ccc16963 ➕ Add the LSP protocol dependency
The autocomplete helper drives tsc --lsp --stdio; vscode-languageserver-protocol
supplies the transport (re-exported vscode-jsonrpc/node) and the typed LSP
requests, replacing the hand-rolled JSON-RPC client. Record the choice and
the rejected alternatives in tooling.md.
2026-09-18 07:14:45 +00:00
tmu 0b97632a7c 📝 Track protocol-backed LSP helper
The autocomplete helper still carries a hand-rolled JSON-RPC client
(Content-Length framing, a pending-request map, server-request replies).
vscode-languageserver-protocol provides all of it plus typed requests, so
track the move as a task with subtasks.
2026-09-18 07:14:18 +00:00
tmu 6f87c2f3b5 🔀 Merge chore/adopt-three-overload-matcher into main 2026-09-17 21:56:41 +00:00
tmu 88159296d4 📝 Drop the stale scripts test glob from testing.md
No tests live under scripts/, and every test script (test, test:unit, test:ci,
watch:test) globs only src/**/*.test.ts, so state that. The scripts/**
oxlint scope it was confused with still exists and carries the same
import/no-nodejs-modules exception as the test-helper scope.
2026-09-17 21:55:41 +00:00
tmu 800c11139d ♻️ Name the matcher factories getMatcher / getMatcherW
Drop the strict/widened aliases from src/index.ts so the factories are
exported under their own names, and align the type tests, the autocomplete
probe (which now imports the public surface from ./index.ts) and library.md
with them.

Also correct the widened-overload comment: the excess-property check does not
apply to a generic P, so MatcherWidening's keyof guard — not the closed
constraint — is what rejects keys outside T/_, as library.md already
documented.
2026-09-17 21:48:54 +00:00
tmu 05fad0fcd6 📝 Record the autocomplete and matcher-design decisions
The matcher is now two three-overload factories; library.md documents why
the union merge, inferred universe, conditional RequireKeys and cases-first
paths were rejected, and lists the two open issues (the fallback sees all of
T; a redundant _ is still accepted). testing.md records the language server
as the autocomplete oracle, and CONTRIBUTING points the exception at the
matcher's own test file.

The backlog marks the design-doc and autocomplete groundwork done alongside
the adoption.
2026-09-17 20:59:28 +00:00
tmu 58d127da4e ✅ Test the LSP completion helper against the server
The helper's own contract — marker handling, requested position, label
extraction and the rejection when the marker is absent — is asserted against
in-memory documents whose contextual types are written inline, so the helper
is tested without coupling to the library's code.
2026-09-17 20:59:19 +00:00
tmu 165bd9e3df ♻️ Adopt the three-overload matcher
strict/widened each take the exhaustive/fallback pattern shape in three
overloads ordered ExhaustiveLoose -> Fallback -> Handlers. TypeScript reads
the first overload for the object-literal popup (_?, a, b) and the last for
the missing-key error, so autocomplete and the error message are tuned
independently. The fallback is a pattern shape, not a factory, so the four
getPrimitiveUnionMatcher* factories collapse to two.

src/index.ts re-exports the two factories; the prototype scratch files that
explored the alternatives are removed.
2026-09-17 20:59:09 +00:00
tmu 02634f41de 🔀 Merge chore/test-util-home into main 2026-09-17 13:33:41 +00:00
tmu 9da5b5dbf2 📝 Note the test-helper home in the changelog 2026-09-17 13:23:36 +00:00
tmu dc57529f0f 📝 Correct the upstream claim in branch.sh
`main` tracks `origin` (ssh); the comment claiming it tracked an
https companion remote was stale.
2026-09-17 13:23:34 +00:00
tmu 484e5c8ca5 📝 Allow the floating-promise header in test files
Agents may add the fixed file-level typescript/no-floating-promises
oxlint-disable header at the top of a `*.test.ts` file — the
expectTypeOf() misidentification is a documented false positive
(development/testing.md § Known issues) and the await/void workarounds
collide with eslint/no-void. Every other suppression stays the human
last resort it was.
2026-09-17 13:23:32 +00:00
tmu c51e32d6a6 📝 Record the test-helper home decision
development/testing.md § Test helpers: why name beats path under
import/no-relative-parent-imports, why the key needs `#`, why the
`__tests__` folder name, and what was rejected (flat placement, tsconfig
paths, rule exemptions).
2026-09-17 13:23:29 +00:00
tmu 9e5522c131 🚚 Home test helpers behind #test-utils/*
src/util/__tests__/ holds shared test helpers, reached from anywhere
in the source tree as `#test-utils/<name>.ts` through
package.json#imports. A bare `~/…` is not a valid imports key — Node
requires `#` — and a direction-free specifier keeps
import/no-relative-parent-imports from ever firing. The `__tests__`
folder name is the pattern tsconfig.build.json already excludes, so
helpers can never ship. Lint scoping: import/no-nodejs-modules off for
the folder (the probe spawns a language server) and
typescript/promise-function-async off to match its promise-returning
style. The LSP probe moves in from scripts/; a smoke test pins the
specifier's resolution and typing without starting a server.
2026-09-17 13:23:27 +00:00
tmu ee146ce350 🚀 Release 0.2.0
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 24s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 17s
2026-09-16 22:07:16 +00:00
tmu 33c6d9dc8d 🔀 Merge chore/remove-example-code into main 2026-09-16 22:06:17 +00:00
tmu 65e885989b 📝 Backlog the README walkthrough restoration
The example-code removal stripped the synopsis and examples with the
template modules; track bringing them back around the real API, with the
previous section order recorded so its placement is not re-guessed.
2026-09-16 22:05:05 +00:00
tmu 16092350b8 📝 Check off example-code removal in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done; retarget the v1.0 coverage tasks at the surviving
primitive module.
2026-09-16 21:58:14 +00:00
tmu a3eb6183af 🔥 Remove the match and pattern example modules
They were the template's demo API, not the library's real surface. Drop
them, their README walkthrough, and the tooling note that cited their
disable directives, and point index.ts at the primitive matchers that
remain.
2026-09-16 21:57:55 +00:00
tmu ce3d618757 🔥 Remove the example index spec
The spec only exercised the template's match/P example API. Removing it
first lets the modules and exports it imports go next without leaving a
dangling reference.
2026-09-16 21:57:42 +00:00
tmu 73e5bc0093 🔀 Merge chore/straighten-oxc-rules into main
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-16 21:50:14 +00:00
tmu 08513b36a9 📝 Check off straighten-oxc-rules in backlog
Note the finished work under [Unreleased] and mark the task and its
subtasks done.
2026-09-16 21:46:04 +00:00
tmu 068d6b4998 ♻️ Use line comments in primitive matchers
Replace the three `/* */` prose blocks with `//` line comments, matching
the rest of the codebase and the comment style the oxlint config now
allows.
2026-09-16 21:46:00 +00:00
tmu fa0b7f2e84 🔧 Allow ternaries and lowercase comments
Turn off `eslint/no-ternary` and `eslint/capitalized-comments` in the
project config: both encode a style the project rejects, so they belong in
the config's rule map rather than a per-site disable.

Scope the adjacent `oxlint-disable` decision to false positives and
record why a rejected stylistic rule is turned off project-wide.
2026-09-16 21:45:54 +00:00
tmu 71ecd508b7 🔀 Merge feature/primitve-pattern-matching into main 2026-09-16 21:40:57 +00:00
tmu c13bc2f408 📝 Document why any is retained in primitive matchers
Rewriting without any was evaluated; the alternatives were more
complex than the current solution. Record the rationale next to the
two oxlint-disable sites so the suppression is not re-litigated.
2026-09-16 21:39:46 +00:00
tmu 8e2691e6aa 📝 Will add backlog items found while working 2026-09-16 20:43:01 +00:00
tmu 16650b07e3 📝 Backlog primitive literal coverage questions 2026-09-16 20:01:20 +00:00
tmu a0f0894339 ✅ Spec mixed string and numeric union keys 2026-09-16 20:01:12 +00:00
tmu ed71929365 ✅ Check matcher handler params
The primitive union matchers invoke every handler with the matched
literal, but the specs wrote their handlers with zero parameters, so
the passed value and its inferred key-literal type went untested.

Declare the parameter on each such handler and pin it with
expectTypeOf, matching the style the two already-correct handlers used.
The `_` fallbacks assert the whole union rather than a single literal.
Return-type expectations are unchanged, so the W / non-W widening
distinctions are still covered.
2026-09-16 19:29:04 +00:00
tmu cce030b8e5 🐛 Type partial W matcher without an any cast
getPrimitiveUnionMatcherPartialW was declared with the intended
P-inferring signature but assigned via `as any`, hiding that the
declared parameter was not assignable to the implementation's. The
union in PatternPrimitiveUnionPartial makes the `_` arm demand
`_ ∈ keyof P`, which the exhaustive arm cannot prove.

Intersect the inference hook Simplify<P> with the implementation's
parameter shape, R pinned to PatternReturns<P>, so the assignment
type-checks while callers keep inferring P from the argument.

Add a spec for the exhaustive (no `_`) pattern.
2026-09-16 19:10:40 +00:00
tmu 2ff67801e0 ✨ Add VS Code node:test debugging and runner config
launch.json: 'Debug current test file' F5 config running
node --test --strip-types on the active file.

settings.json: register .ts with the connor4312.nodejs-testing
extension via nodejs-testing.extensions, using --strip-types (the
repo pins node >=26 with native type stripping) instead of tsx, so
the extension discovers src/*.test.ts and shows Run/Debug lenses
above test() calls in the native Testing UI.

extensions.json: recommend connor4312.nodejs-testing; cspell.json:
allow 'connor' for the extension id.

.gitignore: un-ignore .vscode/launch.json so the config is shared.
2026-09-16 17:34:53 +00:00
tmu 62a5599ab3 📝 Require labeled AAA blocks in tests
Rule in CONTRIBUTING.md; decision, why and rejected unlabeled
ordering in development/testing.md.
2026-09-16 16:00:43 +00:00
tmu 34567856a5 ♻️ Label arrange-act-assert blocks in specs
Rework every test body into // Arrange / // Act / // Assert blocks
separated by blank lines: the factory is arranged once, the matcher is
built from it in a single act, and all type/runtime checks sink to the
end. index.test.ts adopts the same shape.

Also migrate off expect-type's deprecated toMatchTypeOf: the checks
are assignability tests, so toExtend is the faithful replacement.
2026-09-16 16:00:41 +00:00
tmu a25ac6d1e2 ✅ Spec primitive union matchers
Type-driven tests: every expectTypeOf pairs an assert, covering the
header matrix (exhaustive vs partial patterns, strict R vs widened
returns, string and numeric keys, _ fallback wiring).

Negative cases use Parameters<typeof factory>[0] assignability because
expect-type's .not.toBeCallableWith collapses to never on these generic
Simplify<>-wrapped signatures.

Same documented expectTypeOf floating-promise false positive as
index.test.ts, so the same file-level disable applies; testing.md
known-issue updated to list both files.
2026-09-16 14:52:30 +00:00
tmu 40bf652b97 ✨ Add primitive union pattern matchers
Curried matchers over string|number literal unions in four variants
(exhaustive/partial x strict/widened return inference), per the header
matrix in src/primitive.ts.

- Adds type-fest for Simplify/ValueOf.
- Deliberate oxlint escape hatches (as any dispatch) until a cast-free
  formulation lands; see the file comments.
2026-09-16 14:52:21 +00:00
tmu 1e13917cbe 🚀 Release 0.1.8
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 25s
CI / maintain (push) Successful in 15s
CI / publish (push) Failing after 15s
2026-09-16 09:01:49 +00:00
tmu 42281a0f10 🔀 Merge chore/upgrade-deps into main 2026-09-16 08:56:32 +00:00
tmu c15bc0153e 📝 Require concise prose in development/
Actionable sentence in CONTRIBUTING.md § Rules the tools don't enforce;
the why — humans skim, agents imitate the dominant style, so verbosity
compounds — in development/README.md § Decision blocks. Both sides in
one commit per the write-each-fact-once rule.
2026-09-16 08:00:36 +00:00
tmu ae319f6547 🔧 Ignore @types/node in maintain:outdated
DefinitelyTyped keeps the @types/node latest dist-tag on the LTS line,
so check-outdated compared the current-line types against a lower
version: a permanent "reverted" flag, scan exit 1, zero signal. Ignored
by package name; --types filtering and a version-scoped pin rejected
(rationale in development/tooling.md).
2026-09-16 07:55:25 +00:00
tmu 63e8edce79 ⬆️ Upgrade dependencies
@types/node to ^26.6.1 and cspell to ^10.3.2 — the only packages
check-outdated found behind the registry, both bumps within the existing
semver ranges. cspell 10.3.2 drops its transitive
fast-json-stable-stringify. npm run verify is green with no rule or
format fallout. maintain:outdated still flags @types/node as "reverted"
because DefinitelyTyped's latest dist-tag stays on the 22.x LTS series;
known false positive, advisory scan only.
2026-09-16 07:31:49 +00:00
tmu b456a430a7 🚀 Release 0.1.7
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 22s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 15s
2026-09-16 07:23:24 +00:00
tmu 36d8c4feae 🔀 Merge chore/resolve-finish-push-tension into main 2026-09-16 07:21:55 +00:00
tmu d646fd1ec4 ♻️ Let create:branch start from an ahead main
`create:finish` leaves the merge local until `create:release` pushes it, so
`main` is routinely ahead of its upstream between a merge and a release. The
branch front door demanded an exact match and refused until it was pushed,
forcing a manual `git push` that the model deliberately keeps out of it.

Reject only a `main` that is behind its upstream — matching `create:finish`,
which already tolerates being ahead — and record why pushing from `finish` was
rejected instead. The push stays with `create:release`, so the merge remains
reviewable locally, and the known issue about the deadlock is gone.

Resolves: backlog task "Resolve the finish/push tension".
2026-09-15 22:14:51 +00:00
tmu 177d63fa95 🔀 Merge chore/prune-backlog-and-changelog-rule into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 20s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-15 22:09:21 +00:00
tmu f6f820a648 📝 Require a changelog note before finishing a branch
A merged branch must carry a final commit adding a short summary of the
work under [Unreleased] in CHANGELOG.md. create:release derives the bump
heuristic from that body, so notes have to exist before release day;
rationale in development/workflow.md.
2026-09-15 22:08:08 +00:00
tmu c5cc903221 📝 Prune the backlog of closed tasks
All completed and cancelled entries are implemented and reflected in the
docs, CI and changelog; only still-open tasks remain.
2026-09-15 21:58:44 +00:00
tmu cf73857931 🚀 Release 0.1.6
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 23s
CI / maintain (push) Failing after 14s
CI / publish (push) Failing after 15s
2026-09-15 21:53:19 +00:00
tmu 9f038a230d 🔀 Merge chore/docs-restructure into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 20s
CI / publish (push) Skipped
CI / maintain (push) Failing after 16s
2026-09-15 21:51:33 +00:00
tmu 1465926783 📝 Check off the docs restructure and note it in the changelog 2026-09-15 21:51:21 +00:00
tmu 5e7d40b013 📝 Improve docs after split 2026-09-15 21:46:41 +00:00
tmu 74c39e1346 ♻️ Tighten the development/ prose
Same decisions, rationale, rejected alternatives and known issues, said with
less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is
kept; only the wording, duplicated lead-ins and restated context are cut.
2026-09-15 13:56:49 +00:00
tmu 7ea84b66fa ♻️ Drop the last duplicated prose from development/
The GitHub Flow description and the type-first enforcement sentence still
appeared in both CONTRIBUTING.md and development/. development/ now carries only
the context and rationale that is not in CONTRIBUTING.md.
2026-09-15 13:47:01 +00:00
tmu 95d73d11b6 ♻️ Single-home the actionable rules and the rationale
development/ had restated the actionables that CONTRIBUTING.md owns: the
branching step list, the script prefix list, the feedback-tier rule of thumb,
the commit convention and the type-driven test loop. Those now live only in
CONTRIBUTING.md; development/ keeps the decision blocks and links to the rule.
development/README.md, CONTRIBUTING.md and AGENTS.md state the 'write each fact
once' principle explicitly.
2026-09-15 13:46:31 +00:00
tmu fe02317fc8 📝 Track code-fence testing and the finish/push tension
Adds a Documentation task to validate Markdown code fences against src/, and a
Workflow task for the create:finish / create:branch upstream mismatch recorded
in development/workflow.md.
2026-09-15 13:40:04 +00:00
tmu 84d48c6e67 📝 Make the rule/rationale split explicit
AGENTS.md and CONTRIBUTING.md now state that a decision, its rejected
alternatives and its known issues belong in development/<category>.md, that the
actionable rule stays in CONTRIBUTING.md, and that both change in the same
commit. development/README.md names library.md in the category list and spells
out that the sync runs in both directions.
2026-09-15 13:39:58 +00:00
tmu c7372732ba ♻️ Move script-header rationale into development/
branch.sh, finish.sh, release.sh, runner-image.sh and release-notes.sh
carried multi-paragraph 'how we got here / rejected' essays in comments. They
now live in the matching development/ category file, and each script keeps a
one-line pointer so the rationale is single-homed and cannot drift.
2026-09-15 13:27:21 +00:00
tmu 9faa0ecc55 📝 Rewrite README as the user entry point
Follows the Perl/CPAN order: name + one-line description, Synopsis, Description,
Examples, API reference, License. Tooling, CI and decision content moved to
development/; the README keeps only user-facing material and a pointer to
CONTRIBUTING.md and AGENTS.md. No version line and no package.json link.
2026-09-15 13:27:15 +00:00
tmu 73669ec5b6 📝 Rewrite CONTRIBUTING as the contributor entry point
Adds Setup, Development commands, Code style and formatting (with the VSCode
extension hint) and Submitting changes. The decision prose moves to
development/; this file keeps the actionable rules (feedback-tier table, script
prefix list, standards) and links to the category files for the why.
2026-09-15 13:27:05 +00:00
tmu dc7ce22fc5 📝 Add development/ docs for decisions and known issues
Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
2026-09-15 13:26:59 +00:00
tmu 97dfe9e7b4 📝 Expand the docs-cleanup task into concrete subtasks 2026-09-15 13:21:58 +00:00
tmu b92645faa1 🔀 Merge chore/runner-image-decision into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 19s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
2026-09-15 12:09:13 +00:00
tmu 5bb58137c4 📝 Check off the Gitea release-page verification task 2026-09-15 12:07:34 +00:00
tmu e1dec54363 📝 Cancel force-pull task and document the runner-image decision
The act-ci image is rebuilt only when Node is bumped, which changes the
tag, so the runner's default no-force-pull behavior already picks up every
normal update. Forcing a pull would re-pull on every job and a missed
runner-side image change is acceptable, so document that decision as a
known issue with a manual docker rmi workaround instead of tracking a
force-pull task.
2026-09-15 12:02:59 +00:00
tmu c0bba0775c 🚀 Release 0.1.5
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 24s
CI / maintain (push) Failing after 15s
CI / publish (push) Failing after 17s
2026-09-15 09:33:17 +00:00
tmu b8d235df89 🔀 Merge chore/improve-ci-publish into main 2026-09-15 09:32:04 +00:00
tmu 76fe8993a7 📝 Track the docs cleanup and docs-site task 2026-09-15 09:31:58 +00:00
tmu f984576d4e 📝 Record the automatic-token publish design in the backlog
Point 1 dropped the GITEA_TOKEN gate (the run's automatic github.token is
always present), and point 2 added the all-or-nothing check; keep the checked
task from claiming behavior the workflow no longer has.
2026-09-15 09:25:42 +00:00
tmu 79b4d8c005 ♻️ Use the automatic token and fail closed on a partial release
Drop the GITEA_TOKEN gate and pass no explicit token: gitea-release-action
defaults to the run's automatic github.token, so the release page needs only
contents: write. Keep the npm publish gated on NPM_TOKEN, but add a final
always() step that fails the job unless both the release page and npm publish
reported success, so a skipped npm half is an explicit red job instead of a
silently green one.
2026-09-15 09:25:24 +00:00
tmu 60e416b0fe 📝 Check off the CI publish task
The publish job was already tag-only; the coarse NPM_TOKEN assert is replaced by
per-step env gates, so an unset secret now skips only its own step.
2026-09-15 09:06:48 +00:00
tmu e90d2549e3 📝 Document the publish-step token gates
Record the two optional secrets and why the job lifts them into env: the
secrets context is unavailable in a step if, so env is the only place the gate
can read them.
2026-09-15 09:06:39 +00:00
tmu 8915eb8faa 👷 Gate the publish steps on their tokens
The publish job aborted at the top-of-job NPM_TOKEN assert, so every tag run
since 0.1.1 failed before checkout and nothing was ever published. Lift both
optional secrets into job-level env (the secrets context is not allowed in a
step if) and gate each publish step: the Gitea release on GITEA_TOKEN, npm
publish on NPM_TOKEN. An absent secret now skips only its step, so a tag without
the maintainer's secrets stays green after the packaging checks.
2026-09-15 09:06:35 +00:00
tmu 75f2185b32 📝 Track the CI publish hardening task
Also restores the runner force-pull task that the new entry had replaced; it is
still open (CONTRIBUTING § CI runner image documents the stale-image failure).
2026-09-15 09:05:41 +00:00
tmu 9c472c88c2 🚀 Release 0.1.4
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 22s
CI / maintain (push) Successful in 14s
CI / publish (push) Failing after 3s
2026-09-14 16:20:46 +00:00
tmu 16962e947e 🔀 Merge chore/dependency-updates into main 2026-09-14 16:19:04 +00:00
tmu be0ca717d0 ⬆️ Upgrade oxfmt to 0.68.0 and oxlint to 1.83.0
Latest releases of the two oxc tools; both minor bumps within 0.x/1.x
semver ranges. check-outdated now reports zero outdated dependencies
and npm run verify passes with no rule or format fallout.
2026-09-14 16:18:02 +00:00
tmu 5a3ee3b8cc 🔀 Merge chore/remove-toolcache-diagnostics into main
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 18s
CI / publish (push) Skipped
CI / maintain (push) Failing after 14s
2026-09-14 16:07:52 +00:00
tmu 77fb604d7e 📝 Check off the CI Node-baking task
All steps confirmed green in CI ('Found in cache @ /opt/hostedtoolcache/node/
26.8.2/x64', no download). The runner-side force-pull item moves to its own
task, since a changed image under an unchanged tag is still silently ignored.
2026-09-14 16:04:03 +00:00
tmu 1b2b50304e ♻️ Assert the baked tool cache instead of logging it
The diagnostics did their job (missing <version>/<arch>.complete marker, now
written by the image). Replace the noise with a fail-fast assert that names
the same invariant, mirroring the publish job's NPM_TOKEN check: a stale tag
on the runner would otherwise silently reintroduce the per-job download, with
nothing in the repo to catch it.
2026-09-14 16:04:01 +00:00
tmu 048a8870e6 🐛 Write the tool-cache completion marker into the image
CI / release-gate (push) Successful in 3s
CI / build (push) Successful in 39s
CI / publish (push) Skipped
CI / maintain (push) Failing after 15s
setup-node still downloaded although the image carried a perfect
/opt/hostedtoolcache/node/26.8.2/x64/ tree. actions/tool-cache accepts a
cached tool only when the sibling marker <version>/<arch>.complete exists
(tc.find() tests it explicitly); a bare directory is ignored, so the probe
fell through to the download. The marker is what tc.cacheDir() writes after
installing a tool, so the baked entry must create it too.

Job-container diagnostics also confirmed the path was never in question:
RUNNER_TOOL_CACHE=/opt/hostedtoolcache, no mount over it, node -v from the
baked path prints v26.8.2.

CONTRIBUTING records both invariants — the marker, and the fact that a
Dockerfile change keeps the same tag, which forcePull=false can hide.
2026-09-14 15:59:23 +00:00
tmu f0b28c81c0 🔊 Log tool-cache state in the CI build job
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 40s
CI / publish (push) Skipped
CI / maintain (push) Failing after 42s
The custom image provably carries /opt/hostedtoolcache/node/26.8.2/x64/bin/node
(ls inside the image confirms the layout, modes and the 150 MB binary), yet
setup-node still downloads. So the miss is runtime-only: either the runner is
not running that image or something shadows the path inside the job container.
This step prints mounts, the tool-cache listing, a direct node -v from the
baked path and the RUNNER_* env, then setup-node runs unchanged.

Temporary: remove once the cause is known.
2026-09-14 15:55:52 +00:00
tmu 1dfb979ebb 🚀 Release 0.1.3
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 43s
CI / maintain (push) Failing after 33s
CI / publish (push) Failing after 3s
2026-09-14 15:45:11 +00:00
tmu f04ad3d5bc 🔀 Merge chore/fix-ci into main 2026-09-14 15:43:57 +00:00
tmu 93cbfcf6b1 🐛 Expand the node base image through a named stage
CI / release-gate (pull_request) Successful in 2s
CI / build (pull_request) Successful in 37s
CI / publish (pull_request) Skipped
CI / maintain (pull_request) Failing after 34s
COPY --from= resolves its value as a stage name at parse time, before build
args exist, so COPY --from=node:${NODE_VERSION} collapsed to the invalid
'node:' and docker failed with 'failed to parse stage name'. ARGs in global
scope are expanded in FROM, so route through a named nodebase stage; the
per-stage ARG redeclaration keeps the tool-cache paths expanding.
2026-09-14 13:28:29 +00:00
tmu 3e33b51d1b 📝 Document CI runner-image bump ritual
build:runner-image broke the script prefix convention: build is a bare
single-tool command, and no tier fits a docker-daemon + registry-cred action,
so the script leaves package.json and is invoked directly. CONTRIBUTING gains
a 'CI runner image' section recording where the image pushes
(gitea.e1nsnull.de/tmu/act-ci:<version>), the .node-version coupling, and the
three-step Node-bump ritual.
2026-09-14 12:51:34 +00:00
tmu abbdf4410e 📝 Track CI Node-baking work in backlog
Record chore/fix-ci's two done steps and the remaining ops step (build/push
the image; first CI run is the acceptance test). Glyph/tag coupling honoured:
every ✔ line carries @done, open parent stays ☐.
2026-09-14 12:40:22 +00:00
tmu 245dfaf198 💚 Bake Node into the CI job image
setup-node never consults `node` on PATH; its only fast path is a probe of
/opt/hostedtoolcache, which the ephemeral act_runner job containers always
miss, so every job paid a ~50 MB Node download. docker/Dockerfile extends the
runner's default catthehacker/act image with the Node distribution overlaid at
the exact tool-cache layout, so setup-node finds 26.8.2 and skips the fetch
while node-version-file, cache: npm and registry-url keep working unchanged.

Image layers dedupe against the base the host already pulled and prune via
normal docker hygiene — the cleanup story a host bind of /opt/hostedtoolcache
lacks. To make the bake deterministic, .node-version is pinned to the exact
26.8.2 the image carries; scripts/runner-image.sh guards that coupling and
builds/pushes the tag the three node jobs now reference via container.image.

Ops follow-up (outside the repo): build once with
`npm run build:runner-image -- --push` on a machine with registry creds. If
the package is private, the runner needs container registry credentials in
its config.
2026-09-14 12:37:58 +00:00
tmu b9fe21175f 💚 Reuse npm cache in publish job
The publish job ran npm ci against a cold cache on every release while
build/maintain already share a warm node-cache key off the same lockfile.
Tag runs can read caches saved on main, so wiring cache: npm here is free.
2026-09-14 12:13:38 +00:00
tmu 5292455dbc 🚀 Release 0.1.2
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 36s
CI / maintain (push) Successful in 27s
CI / publish (push) Failing after 3s
2026-09-14 12:03:32 +00:00
tmu d1ef039688 🔀 Merge chore/dependency-updates into main 2026-09-14 12:02:51 +00:00
tmu c65f86e5d5 ⬆️ Upgrade dependencies 2026-09-14 12:02:27 +00:00
tmu b5bfe83140 🚀 Release 0.1.1
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 34s
CI / maintain (push) Failing after 27s
CI / publish (push) Failing after 4s
2026-09-14 11:58:51 +00:00
tmu 60bf1bb3a9 🔀 Merge chore/dependency-updates into main 2026-09-14 11:58:11 +00:00
tmu 24bd0270c0 ⬆️ Upgrade dependencies 2026-09-14 11:57:34 +00:00
tmu 475136c1e5 📝 Check off done tasks 2026-09-14 11:55:28 +00:00
tmu 67e3e13b5e 🚀 Release 0.1.0
CI / release-gate (push) Successful in 2s
CI / build (push) Successful in 37s
CI / maintain (push) Failing after 30s
CI / publish (push) Failing after 4s
2026-09-14 11:48:55 +00:00
tmu 595759ca7a 🔀 Merge feature/setup into main 2026-09-14 11:47:58 +00:00
tmu 8495adc47b 👷 Retitle release commits to 🚀
The release commit message is a machine-read convention (release-gate
in ci.yml skips the redundant main CI run on it), so the emoji carries
weight: change the mint in scripts/release.sh, the recognized pattern,
and both docs in one atomic commit. No tags or release commits exist
yet, so nothing historical parses differently. 🚀 matches the
gitmoji semantic (deploy/publish) better than 🔖 here.
2026-09-14 11:47:00 +00:00
tmu 3df672d306 👷 Skip main CI run for release commits
A release pushes main and then a tag pointing at the same commit, so
the branch run re-verifies the identical SHA the tag run already
verifies (and publishes). Add a cheap release-gate job that recognizes
the '🔖 Release x.y.z' commit message on main and skips the
full build/maintain jobs; tag, PR, and ordinary main pushes are
unaffected, and the gate fails open (runs CI) if it errors.
2026-09-14 11:41:49 +00:00
tmu 42cbe2194e ♻️ Finalize changelog notes before pubv
pubv's bump heuristic reads [Unreleased], so the maintainer must write the
notes before pubv runs, not after. pubv refuses a dirty tree (its prompt
defaults to No), so the edit is committed as a staging commit and folded back
into pubv's single release commit.
2026-09-14 11:16:47 +00:00
tmu 9dd973a950 🐛 Refresh origin/HEAD before pubv preflight
pubv resolves the default branch from the local refs/remotes/origin/HEAD,
which git fetch never updates, so a clone or default-branch change left it
stale and pubv warned that main was not the default. Refresh it from the
remote before pubv runs, and assert releases are cut from main explicitly.
2026-09-14 11:08:59 +00:00
tmu 034296f011 ✨ Add create:finish merge front door
The merge half of the branching model was still prose, so it drifted per
session. create:finish mirrors create:branch: it asserts the merge-side
preconditions, fast-forwards a stale main (divergence is refused), merges
--no-ff, runs npm run verify, and deletes the branch only when green. The
push stays with create:release so the merge is reviewable first.
2026-09-14 11:08:59 +00:00
tmu 78bec7a5eb ✨ Emit precompressed sidecars for served assets
Add scripts/precompress.ts, a dependency-free Node 26 tool that writes
.br/.gz/.zst sidecars next to every text asset and keeps the originals, so
static-web-server can serve the variant matching Accept-Encoding and fall
back to the original. The coverage publish step runs it on the copied report.

Wire scripts/*.ts into the toolchain: type-check (tsconfig include), lint and
fix (oxlint paths), knip entry (CI invokes the file rather than importing it),
and a narrow .oxlintrc override allowing node:* imports in Node CLI scripts.
2026-09-13 20:46:59 +00:00
tmu 224afa9afb 👷 Publish tag coverage to the pages server
The build job bind-mounts the shared pages tree (the runner whitelists it
via container.valid_volumes) and, on tag pushes, wipes
/data/gitea-pages/<owner>/<repo>/<tag>/coverage before copying the c8 report
into it. Only this tag's coverage/ is touched; older tags and sibling
docs/landing trees are left for manual pruning.

Add a `tags: ["*"]` push trigger: a `branches` filter alone matches no tag
ref, so the tag-gated publish job (and this coverage step) could never run.

Track per-branch coverage as a backlog task.
2026-09-13 20:38:59 +00:00
tmu a865b466bd 👷 Add Gitea release page to CI
The publish job now opens a Gitea release for the tag, using the
matching Keep-a-Changelog section as the body via scripts/release-notes.sh.
It runs before npm publish so a broken page fails CI without burning an
npm version; npm publish stays the last step.

Uses the first-party gitea-release-action (the older actions/release-action
is deprecated and requires asset files) and contents: write for the run's
automatic token.
2026-09-11 22:06:05 +00:00
tmu 7216c418d0 📝 Add a few new ideas to backlog 2026-09-11 20:54:16 +00:00
tmu 1191f3c4d9 📝 Prune the backlog of closed tasks
All completed and cancelled setup-phase entries are resolved and reflected
in README/CONTRIBUTING/ci.yml; the only still-open items remain.
2026-09-11 20:30:16 +00:00
tmu eee73a151d 📝 Point feedback tiers at ci.yml as the source of truth
The CI build row and the publishing workflow restated a step list that
had already drifted from the workflow (build + publint in build, publish
consuming the build artifact). Reference .gitea/workflows/ci.yml instead
of duplicating the job graph.
2026-09-11 20:23:36 +00:00
tmu 4d92ce9f9a 📝 Check off dist-output v1.0 tasks
The emit work (rewriteRelativeImportExtensions, inlineSources, prebuild
clean) resolved the v1.0 dist/ output block, and attw is covered by the
CI publish tier rather than a standalone checklist item.
2026-09-11 20:23:32 +00:00
tmu b29f924416 🔧 Clean dist before each build
tsc does not prune orphaned emit output, so dropping declarationMap left
stale *.d.ts.map files in dist/ until a manual clean. A prebuild hook now
empties dist/ first, keeping the build reproducible. It deliberately leaves
coverage/ alone, since the manual `clean` resets both.
2026-09-11 20:04:33 +00:00
tmu a0dd187042 📝 Document the TypeScript 5.0 type floor
Consumers need TypeScript >= 5.0: the emitted declarations use const type
parameters and keep relative .ts specifiers, both of which resolve only on
TS >= 5.0. That also makes the emitted .ts specifiers a non-issue, so the
backlog item is closed without a .d.ts post-step: TS <= 4.9 cannot parse
the declarations anyway. The README/CONTRIBUTING wording now says the
rewrite applies to the JavaScript output, not to the declarations.
2026-09-11 19:58:36 +00:00
tmu e7a058d608 🔧 Inline sources and drop declaration maps
The JS source maps now embed their sources (inlineSources), so a debugger
resolves into src/ even though the package ships only dist/. declarationMap
is dropped: a .d.ts.map cannot embed source and would dangle against the
unshipped src/.

Also removes a duplicate backlog entry for the same task and closes the
sourcemap item.
2026-09-11 19:50:50 +00:00
tmu e96c3f89ac 📝 Hand over the two open emit items
Record what a following agent cannot cheaply reconstruct for the
remaining setup-phase items (.d.ts .ts extensions, sourcemap sources):
the verified current emit state, the fact that every existing gate is
already green so no red signal will confirm the fix, that the durable
proof is a consumer-resolution typecheck rather than exit 0, and that
the two items share the emit surface so they belong in one change.
2026-09-10 23:53:46 +00:00
tmu 78aa97d670 📝 Correct the pi-lsp false-positive finding
The TS1295/TS1287 errors were not an LSP misconfiguration: the server
was still running from an earlier local test that had emptied
package.json, and kept the corrupted project state alive. Driven with
pi-lsp's exact handshake, tsc --lsp --stdio reads tsconfig.json
correctly. Record the real caveat instead: no file watchers, so
tsconfig.json/package.json edits can leave diagnostics stale until the
server restarts.
2026-09-10 23:49:09 +00:00
tmu 13ea134d05 📝 Document the .pi extension install model
.pi/settings.json is the shared, committed declaration and .pi/npm/ is
a gitignored cache pi recreates on a trusted startup, so the generated
.gitignore is intentional and nothing needs tracking. Record this in
the README tooling notes and close the reproducibility item as
resolved by design.
2026-09-10 23:37:23 +00:00
tmu b18e03ff97 📝 Keep verify free of the emit path
verify is the local quick source-correctness check; the emit path is
already gated by CI's build job, so adding build to verify would only
add inner-loop friction for something CI covers.
2026-09-10 23:31:45 +00:00
tmu 0ceb5a586e 📝 Confirm pre-push stays a local quick check
Pre-push runs npm test only. That is intended: the staged-file checks
are pre-commit's job and whole-project correctness is CI's, which
checks the pushed commits' content. Dirty working-tree files are out
of scope for a local check that CI backs up, so no dirty-tree guard is
added.
2026-09-10 23:25:04 +00:00
tmu fe9d59f8d4 👷 Publish the dist built by the build job
The publish job re-ran npm ci + build from scratch, discarding the
output that check, test:ci and publint had already gated, and paying
the build twice on a tag push. Upload dist/ as an artifact from build
and download it in publish instead. npm ci stays for the publint/attw
binaries; only the redundant rebuild is gone.
2026-09-10 23:05:46 +00:00
tmu 303544796e 👷 Run publint in the CI build job
publish:publint and publish:attw only ran in the tag-triggered publish
job, so a packaging break stayed green until release. Add publint to
build (offline, fast, packs the built dist). attw stays in publish,
where the full resolution matrix is worth the cost.
2026-09-10 23:03:14 +00:00
tmu 968a478c93 ♻️ Use rm for the clean target
The node -e rmSync one-liner was carried over from the build-tool
switch and only existed for cross-platform shell safety. The repo is
POSIX-only (sh scripts, Node 26, Linux CI), so rm -rf is simpler and
consistent with the rest of the tooling.
2026-09-10 22:56:12 +00:00
tmu e86709f593 📝 Sweep doc and formatter drift
README named --experimental-strip-types, which was renamed to
--strip-types. Document the current flag only.

The extension recommendations list is unchanged, but the VSCode section
undercounted it and now matches extensions.json. The per-language
formatter blocks in .vscode/settings.json stay (they keep a user's
local formatter settings from overriding the project's) and gain the
JSX/TSX language ids to match what oxfmt and the lefthook glob cover.
2026-09-10 22:49:01 +00:00
tmu 1709fbe842 🔧 Ignore pubv in knip via knip.json
pubv is a CLI invoked by release.sh, never imported, so knip reported
it as an unused devDependency on every advisory run. Add a knip config
with an ignoreDependencies entry; verified removal of the entry
reproduces the report.
2026-09-10 22:43:04 +00:00
tmu 0b461c233f 🔥 Drop redundant main/types fields
exports already declares types + import for the root entry, so the
top-level main/types pair was duplicate metadata. publint and attw stay
green with exports alone, verified against a packed consumer install.
2026-09-10 22:42:32 +00:00
tmu 8d1a03f025 🔧 Remove coverage in clean target
clean only wiped dist, leaving the gitignored coverage/ tree to
accumulate across runs. Remove both so a clean checkout state is
actually reachable.
2026-09-10 22:42:14 +00:00
tmu b7d1dbdf06 📝 Track setup-phase review findings in the backlog
Record the tooling, CI and docs gaps found reviewing the setup phase
(src/ is placeholder code and was excluded from the review).

The entries cover pi-lsp install reproducibility, gating the emit path
in verify, .ts extensions left in emitted .d.ts, pi-lsp false-positive
diagnostics, CI packaging checks and artifact reuse, pre-push gate
strength, plus low-impact cleanups (clean target, redundant fields,
knip false positive, sourcemap sources, doc drift).
2026-09-10 22:37:59 +00:00
tmu 99bda289a6 📝 Document .editorconfig as oxfmt fallback
Recommend `EditorConfig.EditorConfig` in .vscode/extensions.json so editors
without native `.editorconfig` support pick it up, and add an "Editor
configuration" section to CONTRIBUTING.md: `.editorconfig` is an
editor-compatibility fallback for the file types oxfmt does not format
(shell scripts, dotfiles, the commit-message template, git's
`COMMIT_EDITMSG` buffer), with `.oxfmtrc.json` authoritative where both
apply. `.editorconfig` itself is unchanged.
2026-09-10 21:47:08 +00:00
tmu 0c6d1483de ♻️ Port CI workflow from GitHub Actions to Gitea Actions
The runner now lives on Gitea; move the workflow to .gitea/workflows/ and
delete the stale .github copy (Gitea ignores .github/, so it's drift bait).
Use the native gitea.ref context for the tag gate; keep actions pinned to
their GitHub sources (the runner has internet + caches them). Drop the
upload-artifact coverage step in favour of the self-hosted webserver plan
tracked in the backlog. Add an empty-secret gate as publish's first step so
a tag push without NPM_TOKEN fails loudly instead of silently no-oppping.
2026-09-10 19:41:09 +02:00
tmu 9a08262076 📝 Track self-hosted coverage hosting plan in backlog
Record the decision to host CI coverage from a tiny dir-listing server in
the Gitea docker setup (shared volume keyed by repo/tag) instead of the
GitHub upload-artifact zip. This unblocks dropping the coverage artifact
from the Gitea CI port. Adds the lipanski docker-image author to cspell.
2026-09-10 19:40:35 +02:00
tmu 672fd1f7e4 ♻️ Adopt setup: prefix and retire the stray use: script
Introduce a setup: prefix for one-time clone configuration (mutates the
local environment, never a hook or CI step) with setup as its umbrella
aggregator. Move use:git-commit-message to setup:git-commit-message as
its first member, retiring use: — the undocumented prefix tracked in the
backlog. Update both CONTRIBUTING.md prefix lists, the bare-command
inventory, and the stale use: reference in branch.sh.
2026-09-08 23:01:49 +02:00
tmu 844e80d650 ♻️ Group workflow scripts under a create: prefix
`branch` and `release` were bare commands, but bare in this repo means
"how you invoke a tier" or "runs one tool" — neither fits a command that
opens or closes a unit of work. They get their own prefix now, since a
prefix is how this repo records _when_ a script runs.

`create:` because both members genuinely create something (a branch, a
release) and it is a plain verb rather than VCS slang. No bare `create`
aggregator: running "all the workflows" describes nothing anyone wants,
and `publish:*` already precedents a prefix without one.

The rule "reuse an existing prefix, never invent one" now says what it
actually means: a new prefix is allowed when the scripts belong in the
pipeline, provided it enters both lists in the same commit as its first
member. That is the lesson from `use:`, which is referenced by prose yet
in none of the lists — already tracked in backlog.tasks.

The bare-command sentence shrinks to `build`, `clean`, `verify`.
2026-09-08 22:51:35 +02:00
tmu 8c2855aa92 📝 Track the undocumented use: prefix in the backlog
`use:git-commit-message` is referenced by CONTRIBUTING.md but appears in
none of the lists that define the script vocabulary, so the rule "reuse
an existing prefix, never invent one" is currently violated by its own
exception.

Recorded as a task rather than fixed here: the prefix wants a name that
says what it is for, and that is the same decision as naming a prefix
for the other lifecycle scripts, so the two should be chosen together.
2026-09-08 22:32:05 +02:00
tmu 8efd13b2f4 ✨ Add branch front-door command
The branching model assumed a clean, current `main` and a green baseline
before any edit, but both were prose, and prose nobody checks silently
becomes a suggestion. `npm run branch -- <prefix>/<desc>` asserts the
precondition and only then creates the branch, so a later failure is
attributable to the change that caused it.

Checks run cheap-first (`--porcelain` deliberately catches untracked
files, which would otherwise ride onto the new branch) and the suite
runs last, so an ineligible tree never pays for it. `main`'s remote is
derived from its upstream rather than hardcoded: this repo has both
`origin` and `origin_https`, and `main` tracks the latter, so a
`git fetch origin main` currency check would compare against a ref that
is never updated here. The baseline runs after switching to `main`, so a
red `main` restores the starting branch instead of stranding the caller
on it.

The prefix stays a judgment call: the script validates it against the
documented vocabulary instead of inferring it.

Prose kept as index only — AGENTS.md points agents at the command from
the task workflow, CONTRIBUTING.md owns the model and the bare-script
tier (dropping its stale hardcoded count of "two" conveniences).
2026-09-08 22:14:34 +02:00
tmu 93cb37441d 📝 Document pi-lsp tooling and agent usage
README § Tooling and § Tooling decisions record the extension and the
reasoning behind it: read-only by design, auto-detects TypeScript 7,
and never a correctness gate — verify is. AGENTS.md § First action tells
agents the lsp_* tools exist, to prefer lsp_references over grep -w for
colliding identifiers, and to treat empty LSP output as inconclusive.

Close the backlog evaluation task with a resolved note, and allowlist
the tsgo/tsserver tool names for cspell.
2026-09-08 20:47:40 +02:00
tmu 863198d472 ✨ Adopt @spences10/pi-lsp project-local
Declare the read-only LSP extension in .pi/settings.json so it is
shared with the team and auto-installed on project trust.

Pin 0.0.46 explicitly: a bare `pi install` writes `^0.0.10`, and in
semver a caret on 0.0.x pins to exactly 0.0.10, which hard-wires
typescript-language-server and predates TypeScript 7. 0.0.46 detects
the repo's own typescript@7 (no lib/tsserver.js) and spawns
tsc --lsp --stdio against it — no extra server package required.

Commit pi's own .pi/npm/.gitignore sentinel rather than adding a rule
to the project root .gitignore: pi put the file there deliberately, so
leaving it in place (and tracked) is the least-surprise option for a
human reading the tree.
2026-09-08 20:47:22 +02:00
tmu 15aef06e32 🐛 Require @done on every ✔ backlog line
The sandy081.todotasks extension marks a line done from the ✔ glyph
alone, then unconditionally decorates its @done tag via
lineText.indexOf("@done"). On a bare ✔ that returns -1, so the
decorator builds a Range with a negative character offset, VS Code
throws, and all decoration/highlighting for the document dies.

The committed backlog.tasks contained two such lines (the branching-
model subtasks), so re-opening the file reproducibly broke
highlighting. Add @done to them and document the required ✔/@done
(and ✘/@cancelled) coupling in AGENTS.md.
2026-09-08 14:56:47 +02:00
tmu b50149aad6 📝 Document 'release' as a bare script in prefix convention
'release' (./scripts/release.sh) runs no tool and aggregates no
prefix:* family, so it fits neither a tool entry point nor a tier
aggregator. List it among the bare conveniences alongside 'verify'.
2026-09-08 14:07:36 +02:00
tmu 06bc6bc43e 📝 Document GitHub Flow branching model and agent task workflow
Adopt single-developer GitHub Flow: branches off main with a
feature/fix/chore prefix, merged back via 'git merge --no-ff' (local
PR). Releases are not triggered by pushes; only the maintainer runs
'npm run release', which tags and pushes; CI publishes to npm on the
tag. Gitea is the lab; GitHub is reserved for later promotion.

Replace the 'not yet settled' backlog note in AGENTS.md with concrete
agent instructions: a task with subtasks gets a branch, a leaf task is
worked on the current branch, and a fixed handover template (Implemented
/ Judgement calls / Known problems) frames the pre-merge review. Drop
the stale 'MR' reference in 'Never do' (no MR workflow). Update the
feedback-tier table and publishing workflow to gate CI on push to main,
not PRs. Check off the branching-model backlog group and open a task
for the release CI workflow (blocked on a missing Gitea runner).
2026-09-08 14:07:33 +02:00
tmu 75d605fabe 📝 Add backlog.tasks template and format rules
Introduce backlog.tasks, a project backlog in vscode-todotasks format
(not Markdown), seeded as a reusable template: Setup, v1.0, Bugs,
Enhancements, Documentation and Maintenance projects, with the
implementation tasks to be filled in later.

Document the format in AGENTS.md so agents can read and update the file,
recommend sandy081.todotasks in .vscode/extensions.json, and whitelist
"todotasks" for cspell, which both new mentions otherwise fail.

The branch, review and release policy is deliberately not asserted here:
it is unsettled, so it is tracked as a task-group under Setup and AGENTS.md
tells agents to take branch and merge instructions from the user until that
work is documented.
2026-09-08 11:42:46 +02:00
tmu 837203c29e 📝 Rename test-loop "blue" phase to "type"; cite TDD
The type-first spec step was labelled "Blue", which collides with the
Red-Green-Blue convention where blue means the refactor phase (and our
loop already has an explicit Refactor step). Rename phase 1 to Type and
describe the loop as type -> red -> green -> refactor.

Tie the discipline to its established name: Type-Driven Development
(Edwin Brady), quoting the Idris framing -- treat the type as the plan,
let the compiler/type-checker drive you to a program that satisfies it.
Section heading becomes "Testing discipline (type-driven)"; update the
AGENTS.md pointer + anchor. Add "idris" to the cspell dictionary.

De-duplicate: CONTRIBUTING.md no longer re-lists the banned directives
(@ts-ignore, as casts, ...); it links to AGENTS.md "Never do", the
single home for that rule, so the lists can't drift.
2026-09-07 23:44:35 +02:00
tmu c7f566500e ⬆️ Upgrade dependencies to latest versions 2026-09-07 23:01:33 +02:00
tmu 09c5b129bb ✨ Add pubv-based release.sh and npm run release
Release front-door: pubv runs preflight + the interactive semver
heuristic and graduates CHANGELOG.md [Unreleased] -> dated; npm version
syncs package.json + lockfile; git commit --amend folds them into
pubv's single release commit; the tag is created AFTER the amend so it
marks the exact commit on main that gets published; then push. Tags are
v-less (--tag-prefix=none) to match the changelog compare refs.

@runwisp/pubv added as a devDependency. The short "how we got here /
what was rejected" note lives as a comment atop the script; the docs/
exploration was removed as too much to read.
2026-09-07 18:17:28 +02:00
tmu b9fb054176 🔧 Point repo metadata at Gitea; drop redundant .npmignore
The repository.url shipped github.com/tmueller/... but the real remote
is gitea.e1nsnull.de/tmu/..., so the npm page's Repository link 404'd.
Add homepage + bugs, fix the URL, and rename the CONTRIBUTING "GitHub"
reference to the forge. Delete .npmignore: when a package.json `files`
allowlist is present npm never consults .npmignore, so it was dead
weight that would only drift. Add "gitea" to the cspell dictionary.
2026-09-06 14:42:35 +02:00
tmu 464789b385 📝 Document type-first (red/green/blue) testing discipline
New behavior is written type-first: the expectTypeOf (blue) comes
before the assert (red), since for a pattern-matching library the
types are the feature. Adds a CONTRIBUTING.md section and a
discoverability pointer in AGENTS.md's "Read these" index.

Human-facing rationale lives in CONTRIBUTING.md; AGENTS.md only links
it so agents and reviewers don't diverge.
2026-09-06 01:08:09 +02:00
tmu 49a78f3683 📝 Deduplicate docs into one home per fact
The same facts were restated in up to three files, and the
restatements had already drifted: two claimed timings were 2-5x
off, and the maintain rationale existed in four copies with
different numbers each. Duplicated prose is a liability, not
redundancy.

Each fact now has exactly one owner, with links from the others:

- tool inventory: README § Tooling
- configuration rationale: README § Tooling decisions
- prefix taxonomy: CONTRIBUTING § Script prefix convention
- what runs when and at what cost: CONTRIBUTING § Feedback tiers
- publish procedure: CONTRIBUTING § Publishing workflow

README loses its whole Script prefix convention section and the
Workflows section; CONTRIBUTING loses the Why-these-splits bullets
that repeated the tier table and the rules list. Prose is 23KB to
17KB, with no rule or rationale dropped.
2026-09-06 00:45:15 +02:00
tmu 892aead383 🐛 Fix commit template setup with git config
Copying the template into .git/COMMIT_EDITMSG has no lasting
effect: git pre-fills that file from the commit.template config,
so the script registered nothing and the template never appeared.
Use git's own mechanism, which is what git reads for every
interactive commit.

Name, prefix and call site are unchanged, so README's setup
bullet stays correct; only the sentence describing the old copy
in CONTRIBUTING needed rewording. Note the config holds a path
relative to the working directory, so it applies to commits made
from the repo root.
2026-09-06 00:26:22 +02:00
tmu 682ecf1163 ✨ Enforce pinned Node via engine-strict
The `engines` field is advisory by default: an install run under an
unpinned Node only emits a notice and still succeeds, so it rewrites
package-lock.json with that older npm's resolution rules. That is not
hypothetical — it happened while dropping the redundant platform
bindings, and the resulting lockfile was rejected by `npm ci`.

With engine-strict the mismatch is a hard failure instead, so the pin
in .node-version is actually load-bearing for lockfile integrity.
Verified: node 24 install now aborts with EBADENGINE, node 26 stays
green (`npm run verify`, `npm ci` in sync), and npm auto-excludes
.npmrc from the published tarball.
2026-09-06 00:13:12 +02:00
tmu 8b4722da26 🔥 Drop redundant optionalDependencies block
oxlint, oxfmt and oxlint-tsgolint each declare their own platform
bindings as optionalDependencies gated by os/cpu, so npm already
installs exactly the one matching the host. Listing all 20 at the
root duplicated that: the lockfile diff shows no package added or
removed, only re-tagged as a dev transitive.

The root list was also the sole reason maintain:outdated carried a
20-package --ignore-packages hack, since check-outdated scans root
deps. Without it the script is one flag and reports clean.

Verified on the pinned node 26: fresh `npm ci` in sync, `npm run
verify` green, type-aware oxlint still fires (no-floating-promises),
and `npm install --os/--cpu` dry-runs resolve the darwin-arm64 and
win32-x64 bindings from the lockfile.
2026-09-06 00:09:33 +02:00
tmu da5ab79f9b ♻️ Replace verify skill with an npm verify script
A skill duplicated AGENTS.md policy and only worked in Agent-Skills harnesses.
A bare top-level script is the repo-native affordance: every harness (and CI,
and a human) reads package.json#scripts as the source of truth. Add
`npm run verify` = `npm run check` + `test:unit` (tsc runs once, since check
already type-checks) as the one-shot whole-project correctness gate.

Delete .agents/skills/verify/SKILL.md. Document verify in README (Development +
a Tooling-decisions bullet + bare-command note in the prefix list), CONTRIBUTING
(feedback-tier table, "Before pushing", the bare-command paragraph, a "why these
splits" bullet), and AGENTS.md (test = fast iterating gate, verify = definition
of done; skill pointer removed).
2026-09-05 21:46:59 +02:00
tmu 3d0fe18d6a ✨ Add verify skill for the agent loop
A project-local Agent Skill (.agents/skills/verify/SKILL.md) wraps the
correctness ladder as an on-demand procedure: run npm run test, recover by
fixing the root cause in the types (not by suppressing them), optionally run
whole-project npm run check, and commit through the hook. Exposed as
/skill:verify for pi, and read by any Agent-Skills harness from .agents/skills/.

It is a procedure, not a doc fork: it links AGENTS.md / CONTRIBUTING.md /
package.json instead of restating the rules, so there is no drift.

Agents must not start `npm run watch` -- it never returns and blocks the turn.
The skill's ladder drops watch and calls it out under "What not to run here";
AGENTS.md gains a matching guardrail.
2026-09-05 21:28:44 +02:00
tmu 103cf04f8d ♻️ Run both maintain scans independently (;)
The maintain aggregator used &&, so if maintain:knip exited non-zero (e.g. an
unused export) maintain:outdated never ran and the report was partial. Switch
to ; so both scans always run and every finding surfaces. CI still treats the
job as non-blocking, so the trailing exit code is moot there. Zero new dep.
2026-09-05 18:49:59 +02:00
tmu 7973bf0d0b 📝 Move knip + outdated to a maintain: prefix
`npm run check` should be the fast, offline correctness ladder only (tsc +
oxlint + oxfmt + cspell, ~3s), so an agent can run it as a confirmation gate
during feature work. check:knip / check:outdated were advisory whole-project /
network scans, and check-outdated exits non-zero whenever any dep is behind.
Keeping them in check made `npm run check` (and the CI build gate) fail on
dependency freshness, which must not block an unrelated feature PR.

Rename them to maintain:knip / maintain:outdated, aggregate under `npm run
maintain`, and run it in CI as a dedicated non-blocking job (continue-on-error)
that surfaces findings without ever gating a merge. Update the script-prefix
convention, the feedback-tier table, and AGENTS.md: the agent may now run
`npm run check`; only `maintain` stays out of the feature loop.
2026-09-05 16:47:55 +02:00
tmu 8940537fc0 📝 Add hook-discipline rule to AGENTS.md
The agent must not use `git commit --no-verify` (or skip any hook) to get a
green run — bypassing a check to silence it is the same anti-pattern as
@ts-nocheck or an oxlint-disable. The pre-commit checks are fast and offline,
so a redundant run costs nothing. Records the "redo through the hook" recovery
command.
2026-09-05 16:35:14 +02:00
tmu 436b49e3be 📝 Add AGENTS.md as the agent entry point
AI coding agents read AGENTS.md automatically, not README. Make AGENTS.md
a thin pointer: the stable first-action facts plus links to CONTRIBUTING.md,
README.md, and package.json#scripts. Keep the rules prose in CONTRIBUTING.md
so humans and agents don't diverge (same anti-drift move as dropping
project-specs.md).

Clarify the agent's verification loop: npm run test is the mandatory gate;
the pre-commit hook already runs the fast offline checks (tsc, oxlint, oxfmt,
cspell) on staged files, and npm run check (knip + outdated) is repo-maintenance,
not part of the feature loop. Forbid type-system escape hatches (@ts-nocheck,
oxlint-disable, as-casts) as agent-only rules; a human may still add a
review-visible disable as a last resort, so the location convention stays in
CONTRIBUTING.md with a forward reference.
2026-09-05 16:32:15 +02:00
tmu 8924dd67d3 ✨ Add watch tier with watch:test child
Adds the earliest feedback tier to the ladder: `npm run watch`
re-runs tests on file save, started manually in a dedicated
terminal pane. Built on Node 26's native `node --test --watch`
(no new dependency).

Structure follows the existing prefix convention:
- `watch:test` — runs the test suite in watch mode
- `watch` — umbrella that aggregates `watch:*` children
  (currently just `watch:test`; future `watch:oxlint` etc.
  would aggregate here, switching to concurrent execution)

CONTRIBUTING.md documents the new tier in three places: the
prefix convention list, the feedback tiers table (new top row),
and a 'Why these splits?' bullet explaining why watch is a
manual tier rather than a hook. README's Development section
gains a one-line pointer.
2026-09-05 00:40:41 +02:00
tmu 538272c222 📝 Restore unique maintainer content as CONTRIBUTING.md
After dropping project-specs.md last commit, three categories of
content were lost that have no other home in the repo:

1. The script prefix convention with its 'pick the right prefix,
   don't invent one' rule
2. The feedback-tier system (table + 'why these splits?')
3. The publishing workflow (tagged-release flow)

These are maintainer/contributor-facing material, not user-facing.
The standard OSS location for this kind of doc is CONTRIBUTING.md,
which keeps it separate from README.md (user docs) so the two
don't drift. README.md gains a pointer at the bottom.

The content is condensed: no config dumps, no restatements of
package.json, no historical commit-message examples. Only the
rationale that isn't already in the actual config files.
2026-09-05 00:35:00 +02:00
tmu e78f624cbb 🔥 Drop project-specs.md; absorb unique rationale into README
project-specs.md was a parallel document that restated most of what was
already in package.json, README.md, and the config files themselves.
The only content that wasn't already captured elsewhere was the
'why' behind a handful of non-obvious tooling choices, which now
lives in README.md as a new 'Tooling decisions' subsection.

This eliminates the drift problem between the two docs (the source
of drift in the previous commit) by having one source of truth for
'what' (the config files) and one source for 'why' (README).
2026-09-05 00:21:56 +02:00
tmu 1a749b13d6 📝 Reconcile project-specs.md and cspell.json with the feature/setup branch 2026-09-05 00:13:10 +02:00
tmu 1fe3dbe28b ✨ Add npm run fix aggregator for the fix:* scripts
Override the prior design choice: there is now an npm run fix
script that runs fix:oxlint && fix:oxfmt. Rationale: the friction
of having to remember and run two separate fix commands after
npm run check outweighs the 'intentional fixes' argument once
check has already told you which fixers are needed. The diff
after running fix remains the review surface.

- package.json: new 'fix' script (npm run fix:oxlint && npm run fix:oxfmt)
- project-specs.md: updated the FIX section to document the new
  aggregator, removed the 'no fix aggregator by design' text in
  two places (FIX section and PREFIX CONVENTION section)
- README.md: same updates, plus the Development section header
  changed from 'Format/Fix' to 'Individual fixes' to reflect
  the new hierarchy
2026-09-04 23:58:18 +02:00
tmu 8a8d57172c ⬆️ Update cspell 2026-09-04 23:53:26 +02:00
tmu 7e9401590e ✨ Add pre-push hook that runs the test suite
The pre-commit hook is file-scoped (LEFTHOOK_FILES), so it can't
naturally run the test suite. Pre-push is the right tier for it:
- Runs after all commits are made but before the push leaves
  the machine, catching regressions that span multiple commits
- ~3.5s including the tsc step (negligible vs the typical push
  round-trip to CI)
- Offline and deterministic, same philosophy as pre-commit

lefthook.yml: new pre-push section, sequential (parallel: false
since there's only one command, but the explicit value documents
the intent that this hook runs commands in order rather than
racing).

project-specs.md: updated the CHECK TIERS table to add the
pre-push column with  in it. Updated the rule-of-thumb
list to include the pre-push tier. Updated the 'why' notes to
explain why  lives in pre-push rather than pre-commit
(LEFTHOOK_FILES doesn't apply to the test runner).
2026-09-04 23:41:59 +02:00
tmu 74e4538094 📝 Document the three-tier check system (pre-commit / check / CI)
Adds a 'CHECK TIERS' subsection under the existing HOOKS section
in project-specs.md that explicitly documents:

- Where each check runs (pre-commit, npm run check, CI build, CI publish)
- A summary table mapping scripts to execution contexts
- The rationale for each split (speed, scope, side effects)
  - Why check:knip is not in pre-commit (~4s, whole project)
  - Why check:outdated is not in pre-commit (network dep, advisory)
  - Why publish:* is not in check (validates dist/, needs build)
- Cross-reference from the CI/CD section back to the tiers table

The split is a deliberate design choice: pre-commit is the fast
safety net for what you just changed, npm run check is the full
local audit, CI is authoritative. Documenting it makes the
rationale explicit and stops anyone from re-adding the slower
checks to the hook.
2026-09-04 23:36:32 +02:00
tmu 34e9b52ee8 ♻️ Move type-aware config to .oxlintrc.json; use source-level disable directives
Cleaner separation of concerns:
- options.typeAware: true in .oxlintrc.json activates type-aware
  rules declaratively (equivalent to --type-aware CLI flag, but
  the script command stays clean: just 'oxlint ...')
- Remove the 3 type-aware rule disables from .oxlintrc.json
- Add source-level oxlint-disable directives instead:
  - 4x typescript/no-unsafe-type-assertion (pattern.ts: keysMatch
    Object.keys() cast, candidate[] cast; structuralMatcher value
    as S cast; match.ts: handler as ... cast in nextCases)
  - 1x typescript/no-unnecessary-type-parameters (pattern.ts:
    keysMatch <S extends object>)
  - 1x file-level typescript/no-floating-promises in index.test.ts
    (expectTypeOf() is a sync type-assertion library that the
    type-aware linter misidentifies)

Disabling rules at the source (next to the line that needs the
exemption) documents intent more clearly than a global config
override, and makes the trade-off visible to anyone reading the
code. Re-enabling a rule in the future only requires removing the
inline comment, not editing a central config.
2026-09-04 23:27:58 +02:00
tmu 2b3ef0f721 ✨ Add oxlint-tsgolint for type-aware linting
Activates type-aware rules via oxlint --type-aware, backed by
oxlint-tsgolint (TypeScript-Go). Catches unsafe type assertions,
unnecessary type parameters, and other issues regular oxlint
cannot see.

- Add oxlint-tsgolint devDep + 6 platform-specific native bindings
  as optionalDependencies (same pattern as oxlint)
- Add --type-aware flag to check:oxlint
- Add 6 @oxlint-tsgolint/* platforms to check:outdated ignore list
- Disable 3 type-aware rules in .oxlintrc.json with rationale:
  - typescript/no-unsafe-type-assertion, typescript/no-unnecessary-type-parameters:
    fire on legitimate generic type machinery in keysMatch/MatchBuilder
    that needs type-system restructuring (deferred to a follow-up)
  - typescript/no-floating-promises in test files: expectTypeOf() is
    a sync type-assertion library that oxlint-tsgolint misidentifies

Code simplifications enabled by the new strict checks:
- src/match.ts: drop value as unknown casts (T is already assignable
  to unknown) and the redundant run(value) as R cast
- src/index.test.ts: drop unnecessary 'X' as 'X | Y' assertions in
  match<...>(...) calls (literals are already assignable to the union)

Documentation updates in project-specs.md and README.md. Add
'tsgolint' to cspell word list.
2026-09-04 23:20:02 +02:00
tmu ec98223f8d ✨ Add knip for unused dependency and dead code detection
- Add check:knip using --include dependencies,exports,files
  (skips the noisy 'types' category, which produces false positives
  for libraries whose exported types are part of the public API)
- Remove tslib and type-fest (both caught as unused by knip)
- Add 'knip' to cspell word list
- No knip config file: the --include flag keeps the scope targeted
  without boilerplate, matching the 'keep it simple' principle
- Document in project-specs.md (CHECK section) and README.md
2026-09-04 23:08:56 +02:00
tmu 3fa72820df ✨ Add @arethetypeswrong/cli (attw) as publish:attw
Validates the emitted .d.ts declarations against multiple TypeScript
module-resolution scenarios. Same rationale as publint: it validates
the publishable artifact, not the source, so it belongs in the
publish: prefix, not check:.

- Add publish:attw script using --profile esm-only (the package is
  intentionally ESM-only; CJS resolution is out of scope by design)
- Add step to CI publish job, after publish:publint and before
  npm publish
- Document the script and the esm-only rationale in project-specs.md
  and README.md
- Add 'attw' and 'arethetypeswrong' to cspell word list
- Remove unused 'stricter' word that was added speculatively before
2026-09-04 18:08:23 +02:00
tmu 01d1084e1f 🚚 Rename publint script to publish:publint and document prefix convention
publint validates the publishable artifact (dist/ vs package.json),
not the source. It should run only at publish time, in the CI publish
job, not on every commit.

- Rename check:publint -> publish:publint
- Remove from 'check' chain
- Add 'publish:publint' as a step in the CI publish job, right
  before 'npm publish'
- Introduce a 'publish:' script prefix for publish-time-only scripts
- Document the prefix convention (check:, fix:, test:, publish:)
  in project-specs.md (as a top-level subsection under Scripts)
  and in the README
- Add 'publint' and 'stricter' to cspell word list

A new script should pick the prefix that matches its lifecycle, not
invent a new one. The 'publish:' prefix has no 'npm run publish'
aggregator by design (publishing is CI-only).
2026-09-04 18:01:01 +02:00
tmu 24a3880aad 🐛 Fix test scripts: Node 26 doesn't auto-discover tests in directory args
node --test src/ on Node 26+ treats src/ as a module path, not as a
directory to scan for test files, producing a 'Cannot find module'
error. Switch the test scripts to an explicit glob that lists all
*.test.ts files under src/.

Affects test, test:ci, test:unit.
2026-09-04 17:47:30 +02:00
tmu 7de4604ab4 ♻️ Move emit-only options from tsconfig.json to tsconfig.build.json
- Build-only options (target, declaration*, sourceMap, outDir) live in the
  build config where they belong
- Drop redundant options (composite: false is the default;
  allowSyntheticDefaultImports is implied by esModuleInterop; resolveJsonModule
  is unused)
- Drop lib override to inherit es2025+ libs from @tsconfig/node26
- Add explicit include to tsconfig.build.json (include is not inherited via
  extends, only exclude was carrying the file selection by accident)
2026-09-04 17:46:03 +02:00
tmu b649013d1c ✨ Extend tsconfig from @tsconfig/node26 and @tsconfig/strictest
- Replace hand-rolled strict flags with @tsconfig/strictest
- Pick up Node 26 module/lib/target from @tsconfig/node26
- Add exactOptionalPropertyTypes (the main strictness gain)
- Pin target to es2024 to remain conservative for the published build
2026-09-04 17:33:43 +02:00
tmu c6f7a5d1b0 🔧 Reconcile config/doc contradictions and remove stale entries 2026-09-03 22:08:01 +00:00
tmu 99fd85ca7e 🔧 Resolve oxlint warnings; defer stylistic rules to oxfmt 2026-09-03 21:33:04 +00:00
tmu b3a4e14f45 🔥 Drop typescript.tsdk references; TS 7 is provided by the typescriptteam extension 2026-09-03 21:09:38 +00:00
tmu c3f913fae9 🔧 Fix small issues with the project setup 2026-09-03 21:01:28 +00:00
tmu 0598a9eb4a 🔧 Expand oxfmt to format project config and docs files 2026-09-03 20:58:33 +00:00
tmu 7349d0dec2 🔥 Remove sort-package-json in favor of oxfmt's built-in sortPackageJson 2026-09-03 20:58:17 +00:00
tmu e45592ae00 🔧 Unify lefthook and package.json scripts via LEFTHOOK_FILES env var
lefthook and package.json had parallel command definitions for the
same tools (oxlint, oxfmt, cspell). Consolidate by making lefthook
call the npm scripts, with staged files passed via the
LEFTHOOK_FILES env var. The scripts use ${LEFTHOOK_FILES:-<default>}
so they default to the full project when invoked manually and to
the staged-files list when invoked from lefthook.

Changes:
- package.json#check:oxlint: `oxlint ${LEFTHOOK_FILES:-src}`
  (lints src/ manually; staged files from lefthook)
- package.json#check:oxfmt: `oxfmt --check ${LEFTHOOK_FILES:-src}`
- package.json#check:cspell: `cspell lint ${LEFTHOOK_FILES:-.}`
  (walks CWD manually; staged files from lefthook)
- package.json#check:tsc, check📦 unchanged (no file args)
- lefthook.yml: file-filtered hooks (oxlint, oxfmt, cspell) now use
  `sh -c 'LEFTHOOK_FILES="$0" npm run check:*' {staged_files}` to
  inject the staged-files list into the env. sort-package-json and
  typecheck call npm scripts directly (no file args).
- project-specs.md: document the unification pattern

Why sh -c + env var instead of the simpler 'npm run ... -- {staged_files}':
  'oxlint src file.ts' lints the whole src/ tree *plus* file.ts
  (oxlint doesn't dedupe paths). Setting LEFTHOOK_FILES as an env
  var (which lefthook's 'env:' config does not template) requires
  the sh -c wrapper, but it gives the right semantics: when the
  var is set, only the explicit files are checked; when unset,
  the default (src/ or .) is used.

Verified:
- 'npm run check:oxlint' (no env) lints all of src/
- 'LEFTHOOK_FILES=src/match.ts npm run check:oxlint' lints only that file
- 'npx lefthook run pre-commit' with a staged TS file: cspell output
  shows '1/1 src/match.ts' (only staged file, not whole project)
- All 5 hooks pass on a real staged change
- 'lefthook validate' reports 'All good'
- 'npm run check' exits 0
2026-09-03 18:44:19 +00:00
tmu 45b6c141e8 🔧 Migrate lefthook config to v2 schema
The previous config was a hybrid of v1 (jobs: array) and v1
(top-level commands: block) that no longer validates under
lefthook 2.x. lefthook 2.0.0's schema has top-level 'commands:'
set to 'false'; the v1 jobs: array form still works, but the
orphan named-commands block at the top was silently invalid
(caught by 'lefthook validate').

Migrate to the v2-native 'commands:' (named) form under the hook:

  pre-commit:
    parallel: true
    commands:
      oxlint:
        glob: ...
        run: npx oxlint {staged_files}
      ...

Changes:
- Add 'min_version: 2.0.0' to declare the v2 schema explicitly
- Replace pre-commit.jobs: array with pre-commit.commands: (named)
- Inline the glob on each command (was a top-level property on each
  job in v1)
- Remove the orphan top-level 'commands:' block — it was never
  referenced by any hook, and v2 forbids it
- Update project-specs.md: drop the dangling reference to the
  lefthook 'commands:' block (it was the dead top-level one);
  describe the v2 commands: form

Verified:
- 'lefthook validate' reports 'All good'
- 'lefthook run pre-commit' executes all 5 hooks (oxlint, oxfmt,
  sort-package-json, typecheck, cspell); globs correctly skip hooks
  on non-matching files (e.g. lefthook.yml alone) and run them on
  TS/JS changes
- 'npm run check' exits 0
- 'lefthook dump' shows the normalized v2 config
2026-09-03 18:12:01 +00:00
tmu 9bc87aab87 ⬆️ Upgrade devDependencies to latest major versions
- @types/node 22.20.1 -> 26.4.1
- c8 10.1.3 -> 12.0.0
- check-outdated 2.16.1 -> 3.0.0
- cspell 8.19.4 -> 10.2.1
- lefthook 1.13.6 -> 2.1.12
- sort-package-json 3.7.1 -> 4.0.0
- type-fest 4.41.0 -> 5.9.0

Verified all configs remain compatible without changes:
- cspell 10 reads the existing JSON config (v0.2 format unchanged)
- lefthook 2 accepts the v1-style config (pre-commit.jobs[], commands:)
- c8 12 still wraps 'node --test --strip-types' and produces the same
  text/lcov/html reports
- check-outdated 3 keeps the --ignore-packages and --ignore-pre-releases
  flags we use

Full pipeline passes:
- npm run check exits 0 (oxlint, oxfmt, tsc, cspell, sort-package-json,
  check-outdated all green)
- npm test: 6/6 pass
- npm run test:ci: coverage report generated successfully
- lefthook run pre-commit: hooks execute correctly
2026-09-03 14:08:28 +00:00
tmu 0c3e51c768 ✅ Ignore coverage directory in .gitignore 2026-09-03 13:54:34 +00:00
tmu ab6eb0f34c 📚 Sync project-specs.md and package.json with current state
project-specs.md:
- Replace Prettier/ESLint/Vite/Vitest references with oxfmt/oxlint/oxc
  and TypeScript 7 / node --test
- Drop @tsconfig/strictest note; rules are inlined in tsconfig.json,
  enumerated explicitly
- Document oxlint rule disables (no-undefined, sort-keys, id-length,
  no-named-export) and test-file overrides (no-unused-expressions,
  no-empty-file, no-nodejs-modules, no-magic-numbers)
- Document Node 26 / .node-version / engines.node / CI follow
- Document allowImportingTsExtensions + rewriteRelativeImportExtensions
  for the node --strip-types quirk
- Replace Vite/vitest coverage example with c8 + node --test
- Sync scripts section: drop check:eslint, check:prettier and their
  fix:* counterparts; add check:oxlint/check:oxfmt/fix:oxlint/fix:oxfmt
- Add current source layout (index.ts, match.ts, pattern.ts, index.test.ts)
- Update VSCode integration section with actual settings.json and
  extensions.json contents
- Document the actual commit history with gitmoji prefixes
- Fix 'initilizing' typo
- Drop 'changesets is a devDep' claims (changesets is not installed)

package.json:
- Move @oxfmt/binding-* and @oxlint/binding-* from devDependencies to
  optionalDependencies so the right binding is selected per platform
  (CI on Ubuntu gnu gets the gnu binding automatically, not the
  local musl one)
- Extend check:outdated to ignore the platform-specific bindings (they
  show as 'not installed' on the current platform, which is correct
- Add cspell words: gitmoji, dbaeumer, msvc (the latter for the Windows
  binding variant). Drop the unused 'nocheck' word

README.md:
- Drop the 'changesets is available as a devDep' line; changesets
  isn't installed
2026-09-03 13:42:37 +00:00
tmu cb5b302238 🔧 Remove Prettier from editor formatter config
Prettier is no longer a project dependency and oxc.oxc-vscode handles
formatting for all file types the project touches. Switch [json],
[jsonc], [markdown], [mdx], and [yaml] formatters from
esbenp.prettier-vscode to oxc.oxc-vscode (oxfmt under the hood,
Prettier-compatible for JS/TS and native for JSON/MD/YAML).

Drop 'esbenp' from cspell dictionary (no longer referenced).
2026-09-03 13:09:53 +00:00
tmu 9a242225ee 🐛 Use .ts extensions in imports for node --strip-types
Node's --strip-types does not rewrite import specifiers the way bundlers
and Deno do, so 'import ... from "./match.js"' literally looks for
match.js (not match.ts) and fails with ERR_MODULE_NOT_FOUND at test
time. Fix by switching source imports to .ts extensions and letting
TypeScript's rewriteRelativeImportExtensions do the rewrite at build
emit time.

Changes:
- tsconfig.json: enable allowImportingTsExtensions (works because the
  root config has noEmit: true)
- tsconfig.build.json: enable rewriteRelativeImportExtensions so the
  emitted dist/*.js files keep './match.js' style imports (correct for
  consumers), not './match.ts'
- src/index.ts: import './match.ts' and './pattern.ts'
- src/match.ts: import './pattern.ts'
- src/index.test.ts: import './index.ts'

Verified: npm run test runs tsc --noEmit (passes) and node --test
--strip-types src/ — all 6 tests pass. tsc -p tsconfig.build.json
emits dist/*.js with './match.js' imports as before; consumers see no
change.
2026-09-03 13:05:20 +00:00
tmu 77dc40da07 🔧 Declare Node 26 as the supported runtime
- Add .node-version (single line: '26') so fnm/nvm/volta/mise and the
  VS Code Node version manager extension auto-switch when entering the
  project directory
- engines.node in package.json: bump from '>=22.6' to '>=26' (npm-side
  install-time declaration)
- Drop --experimental-strip-types flag from node --test invocations:
  Node 26 has --strip-types unflagged
- .github/workflows/ci.yml: switch from hard-coded node-version: 24 to
  node-version-file: .node-version in both build and publish jobs so CI
  follows the .node-version file (single source of truth)
- .npmignore: add .node-version (developer-tool file, not for publish)
- .vscode/settings.json: pin js/ts.tsdk.path to node_modules/typescript/lib
  so the editor uses the project's TS 7, not a different global install
- .vscode/extensions.json: swap ms-vscode.vscode-typescript-next for
  typescriptteam.native-preview (the official TS team extension that
  ships nightly TS support, including TS 7)
- cspell: add 'esbenp' and 'typescriptteam' to dictionary
- package-lock.json: regenerated for engines.node >=26

tsconfig.json target/lib are unchanged: es2024 is the highest stable ES
target TS 7.0.2 ships, and Node 26 supports all ES2024 features natively.
2026-09-03 12:45:32 +00:00
tmu e5df17310e ✅ Add expect-type for type-level tests
- Install expect-type@1.4.0 (devDependency)
- New src/index.test.ts exercises the public API with both runtime
  assertions (node:assert/strict inside node:test) and type-level
  assertions (expectTypeOf().toEqualTypeOf, toMatchTypeOf, toHaveProperty)
- Tests are excluded from tsc -p tsconfig.build.json (no .d.ts pollution
  in dist/)
- Type-level checks happen during tsc --noEmit (run by 'npm run check' and
  the test script); expectTypeOf assertions have no runtime side effect,
  so they pair naturally with node --test in the same test() blocks
- cspell: add EDITMSG to dictionary (used in commit-message-template path)
- oxlint test-file overrides: silence no-nodejs-modules (we use node:
  builtins) and no-magic-numbers (literals in tests are fine)

Coverage:
- match() returns a builder with with/exhaustive/otherwise
- P.literal narrows to its literal type, P.type to its typeof target
- exhaustive() returns the union of handler return types
- otherwise() falls back when no case matches
- exhaustive() throws when no case matches
2026-09-03 10:39:15 +00:00
tmu 08384bbc82 ✨ Add initial pattern-matching API
Introduce the core builder and pattern constructors:

- match(value): chain .with(pattern, handler) and terminate with
  .exhaustive() (throws on no match) or .otherwise(handler).
- P.literal, P.type, P.when, P.any, P.shape for pattern construction.
- Each pattern is a Matcher<T> whose matches acts as a type guard, so
  handler parameters are narrowed to the matched type.

The MatchBuilder accumulates handlers and returns a new builder per
.with() call (immutable chaining); finalization runs the cases in order
and returns the first match's handler result.

TypeScript configuration relaxed in .oxlintrc.json:
- Disable eslint/no-undefined (we use undefined as the no-match sentinel)
- Disable eslint/sort-keys (handler order matters; alphabetical would
  be wrong)
- Disable eslint/id-length (T/R/U/V generics are standard TS convention)
- Disable import/no-named-export (false positive for library entry points)

Verified:
- tsc --noEmit passes with strict + noUncheckedIndexedAccess
- tsc -p tsconfig.build.json emits dist/*.js + .d.ts + sourcemaps
- node -e "import('./dist/index.js')" loads and exposes { match, P }
2026-09-03 10:35:34 +00:00
tmu 6c67e91e68 🔥 Remove Vite scaffold leftovers
- Delete public/vite.svg, src/typescript.svg, src/style.css
- Empty public/ directory removed
- .npmignore: drop 'public/' entry (directory no longer exists), add
  .oxlintrc.json and .oxfmtrc.json to ignore list
- cspell: add .oxfmtrc, .oxlintrc, .sortpackagerc to dictionary so
  config-file name references aren't flagged
- lefthook: drop 'outdated' from pre-commit (kept in commands block for
  explicit CI invocation); it shouldn't block commits for being behind on
  upstream patch releases
2026-09-03 10:28:08 +00:00
tmu 078a8c30fd 🔧 Replace ESLint and Prettier with oxlint and oxfmt
- Drop .eslintrc.cjs and .prettierrc, add .oxlintrc.json and .oxfmtrc.json
- oxlint covers correctness/suspicious/perf/style/restriction categories
  plus import and typescript plugins; oxfmt reads the same .prettierrc-style
  options we had before
- Update lefthook.yml pre-commit jobs to run oxlint/oxfmt instead of
  eslint/prettier
- Update .vscode/extensions.json (oxc replaces eslint/prettier/vitest) and
  settings.json (oxc becomes the default formatter for TS/JS)
- Add 'oxlint', 'oxfmt', 'oxc', 'nodenext' to cspell dictionary
- Rewrite README 'Development' section to describe the new toolchain
2026-09-03 10:25:47 +00:00
tmu 3530262261 🔧 Replace Vite/Vitest with TypeScript 7 and node --test
- Use tsc 7 for build (vite no longer needed for a Node library)
- Use node --test with --experimental-strip-types for tests (vitest dropped)
- Use c8 for coverage instead of vitest's built-in coverage
- Add tsconfig.build.json separating typecheck from emit config
- Drop DOM lib, add strict noUncheckedIndexedAccess/noImplicitOverride
- Pin engines.node to >=22.6 (required for --experimental-strip-types)
- Update CI to Node 24 LTS (unflagged strip-types)
- Add @types/node for node:test / node:assert types
2026-09-03 09:54:21 +00:00
tmu cac1349c95 🎉 Initial commit 2026-02-02 13:38:42 +01:00
Thomas Müller 15c8ef7616 🔧 Track .vscode/settings.json for workspace settings 2025-04-29 13:45:02 +02:00
Thomas Müller 0c08a2d395 ✨ Scaffolded src/index.ts entry point for library code 2025-04-29 13:43:53 +02:00
Thomas Müller 1dc94c0dd1 👷 Added GitHub Actions workflow for CI/CD 2025-04-29 13:43:47 +02:00
Thomas Müller 25353662ec 🧪 Added Vitest configuration with coverage 2025-04-29 13:43:40 +02:00
Thomas Müller f6bbcd8b2e 📝 Scaffolded README.md structure 2025-04-29 13:43:20 +02:00
Thomas Müller 45c0bb6423 🔧 Added .npmignore to exclude non-dist files from npm package 2025-04-29 13:43:15 +02:00
Thomas Müller 142989aa2b 📝 Added commit message template 2025-04-29 13:43:11 +02:00
Thomas Müller 4f65d22efe 📄 Added MIT LICENSE 2025-04-29 13:43:07 +02:00
Thomas Müller 02a33c7b44 🔧 Added sort-package-json configuration 2025-04-29 13:42:39 +02:00
61 changed files with 13654 additions and 526 deletions

No files matched your search

-37
View File
@@ -1,37 +0,0 @@
// .eslintrc.cjs
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
'import',
'unused-imports',
'simple-import-sort',
],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/strict-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:prettier/recommended',
],
env: {
es2022: true,
node: true,
browser: true,
},
rules: {
'prettier/prettier': 'error',
'import/order': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'unused-imports/no-unused-imports': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
ignorePatterns: ['dist', 'node_modules', 'public'],
};
+246
View File
@@ -0,0 +1,246 @@
name: CI
on:
push:
branches: [main]
# Releases are tag pushes (`scripts/release.sh` tags bare `x.y.z`). A
# `branches` filter alone matches no tag ref, so without this both the
# tag-gated `publish` job and the coverage publish step never fire.
tags: ["*"]
pull_request:
branches: [main]
workflow_dispatch: {}
jobs:
# Cheap gate that collapses the release double-run. `scripts/release.sh`
# pushes `main` and the tag seconds apart, and the tag points at exactly
# the HEAD commit that push delivers — so the branch run would verify the
# identical tree the tag run verifies anyway (plus `publish`). When a push
# to `main` is headed by a release commit (`:rocket: Release x.y.z`, the
# single commit release.sh creates), the full CI is skipped here and the
# tag run becomes the authoritative one for that SHA. All other pushes —
# PRs, tags, ordinary `main` merges — see `skip=false` and run as before.
#
# Coupling: the pattern below MUST stay in sync with the release commit
# message in `scripts/release.sh`. Failure mode if the tag push ever fails
# after `main` accepted the release commit: no CI fires; fix by re-running
# `git push --tags`.
release-gate:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.decide.outputs.skip }}
steps:
- uses: actions/checkout@v4
- id: decide
env:
REF: ${{ gitea.ref }}
run: |
# Keyed on the ref, not just the message: a tag run checks out
# the same release commit, and `publish` needs its `build`.
if [ "${REF}" = "refs/heads/main" ] &&
git log -1 --format=%s | grep -qE '^:rocket: Release [0-9]+\.[0-9]+\.[0-9]+$'; then
echo 'Release commit on main — the tag run covers this SHA; skipping full CI.'
echo 'skip=true' >>"${GITHUB_OUTPUT}"
else
echo 'skip=false' >>"${GITHUB_OUTPUT}"
fi
build:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
# `image` extends the runner's default job image (catthehacker/act)
# with Node 26 pre-planted in the tool cache layout, so setup-node's
# version probe hits and never downloads (see docker/Dockerfile). The
# tag MUST equal the exact version pinned in `.node-version`; the bump
# ritual is documented in CONTRIBUTING.md § CI runner image. The volume
# bind-mounts the shared pages tree so the
# coverage step below can write into it; the runner whitelists this
# path via `container.valid_volumes` (docker-space `setup/gitea.sh`).
container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
volumes:
- /data/gitea-pages:/data/gitea-pages
steps:
- uses: actions/checkout@v4
# Fail fast when the job container is not the baked image: a stale
# tag on the runner (`forcePull=false` in its pull log) silently
# reintroduces the per-job download. Cheap, and it names the
# invariant.
- name: Assert the baked tool cache is present
run: |
test -f "/opt/hostedtoolcache/node/$(tr -d '[:space:]' < .node-version)/x64.complete"
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm run check
# Compile the prose examples into gitignored tests. Kept as an
# explicit step (not a `pretest:ci` hook) so it is visible in the
# job log and `test:ci` stays a plain command.
- run: npm run create:doc-tests
# Fails the build below 100% coverage on `src/` (`c8 --all --100`);
# the same run produces the report published below. See
# development/ci.md § Coverage threshold.
- run: npm run test:ci
# Publish this tag's coverage to the self-hosted pages server,
# served read-only at
# https://pages.e1nsnull.de/<owner>/<repo>/<tag>/coverage/. Wipe only
# this tag's `coverage/`, so sibling docs/landing trees and older
# tags survive; pruning stale tags is a manual chore. The
# precompress pass emits `.br` / `.gz` / `.zst` sidecars next to
# every text asset, so `static-web-server` can serve the precompressed
# variant and keep the original as fallback.
- name: Publish coverage to the pages server
if: startsWith(gitea.ref, 'refs/tags/')
env:
REPO: ${{ github.repository }}
REF: ${{ gitea.ref }}
run: |
TAG="${REF#refs/tags/}"
DEST="/data/gitea-pages/${REPO}/${TAG}/coverage"
rm -rf "${DEST}"
mkdir -p "${DEST}"
cp -R coverage/. "${DEST}/"
node --strip-types scripts/precompress.ts "${DEST}"
echo "Coverage: https://pages.e1nsnull.de/${REPO}/${TAG}/coverage/"
# Fast, offline packaging gate. `attw` stays in `publish` (it needs
# a pack + full resolution matrix); `publint` packs too but is cheap
# enough to run on every push so a packaging break fails here, not
# at release time.
- run: npm run publish:publint
# Persist the exact dist/ that `check`, `test:ci` and `publint` were
# run against, so `publish` ships those bytes instead of rebuilding
# (which could in principle differ and would pay the build twice).
- uses: actions/upload-artifact@v4
with:
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`.
maintain:
needs: release-gate
if: needs.release-gate.outputs.skip != 'true'
runs-on: ubuntu-latest
continue-on-error: true
# 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
- run: npm run maintain
publish:
if: startsWith(gitea.ref, 'refs/tags/')
# `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.
container:
image: gitea.e1nsnull.de/tmu/act-ci:26.8.2
# The release page is created with the run's automatic Gitea token
# (`github.token`), so it needs `contents: write`.
permissions:
contents: write
# The npm token is optional: `secrets` is not an allowed context in a
# step `if` (see GitHub's context-availability table), so it is lifted
# into job-level `env`, where an unset secret arrives as the empty
# string and skips the publish rather than attempting an unauthenticated
# one. Set NPM_TOKEN in the Gitea repo: Settings → Actions → Secrets.
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
# Same lockfile/key as `build`, and tag runs can read caches
# saved on `main` — without this, every release pays a cold
# `npm ci` despite the warm shared npm cache.
cache: "npm"
registry-url: "https://registry.npmjs.org/"
- run: npm ci
# Consume the dist/ that `build` produced and gated, instead of
# rebuilding here — `publish` must ship the tested artifact.
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: npm run publish:publint
- run: npm run publish:attw
# The Gitea release page is created *before* `npm publish` on
# purpose: a broken page then fails CI without burning an npm
# version. The page is cheap to retry, a published version is not.
# The body is the matching Keep-a-Changelog section; an unknown tag
# makes the extractor exit non-zero, so the page can never go up
# empty.
- name: Extract release notes from CHANGELOG.md
env:
TAG_REF: ${{ gitea.ref }}
run: ./scripts/release-notes.sh "${TAG_REF#refs/tags/}" > release-notes.md
- name: Create the Gitea release
id: gitea_release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
body_path: release-notes.md
- name: Publish to npm
id: npm_publish
if: env.NPM_TOKEN != ''
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }}
# All-or-nothing: the tag is only released once *both* the release
# page and the npm package are up. A skipped npm publish (NPM_TOKEN
# unset) has no `success` outcome, so `always()` reaches this check
# even after a failure and turns the skipped half into an explicit
# red job instead of a silently green one.
- name: Require both releases
if: always()
run: |
GITEA="${{ steps.gitea_release.outcome }}"
NPM="${{ steps.npm_publish.outcome }}"
if [ "${GITEA}" != success ] || [ "${NPM}" != success ]; then
echo "::error::incomplete release — gitea=${GITEA:-skipped} npm=${NPM:-skipped}"
exit 1
fi
+10 -15
View File
@@ -1,24 +1,19 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
coverage
# Generated doc-tests (scripts/create-doc-tests.ts); the folder is kept via .gitkeep
src/doc-test/__generated__/*.test.ts
!src/doc-test/__generated__/.gitkeep
*.log
*.tsbuildinfo
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1
View File
@@ -0,0 +1 @@
26.8.2
+5
View File
@@ -0,0 +1,5 @@
# Turn the `engines` field from a warning into a gate. By default a Node
# version mismatch is only reported as a notice, so an install run under an
# unpinned Node still succeeds and silently rewrites package-lock.json using
# that older npm's resolution rules. Refuse the install instead.
engine-strict=true
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 4,
"endOfLine": "lf",
"arrowParens": "always",
"sortPackageJson": true,
"sortImports": true,
"ignorePatterns": [
"dist",
"coverage",
"node_modules",
"package-lock.json",
"*.tsbuildinfo"
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "import"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "warn",
"style": "warn",
"restriction": "error",
"nursery": "off"
},
"rules": {
"eslint/no-undefined": "off",
"eslint/sort-keys": "off",
"eslint/id-length": "off",
"eslint/capitalized-comments": "off",
"eslint/no-ternary": "off",
"import/no-named-export": "off",
"eslint/one-var": "off",
"import/group-exports": "off",
"import/exports-last": "off",
"eslint/sort-imports": "off",
"import/consistent-type-specifier-style": "off",
"unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off",
"typescript/promise-function-async": "off"
},
"options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true },
"overrides": [
{
"files": ["**/*.test.ts"],
"rules": {
"no-unused-expressions": "off",
"no-empty-file": "off",
"import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "off",
"unicorn/no-null": "off",
"typescript/no-floating-promises": "off"
}
},
{
"files": ["scripts/**/*.ts"],
"rules": {
"import/no-nodejs-modules": "off"
}
},
{
"files": ["src/util/__tests__/**"],
"rules": {
"import/no-nodejs-modules": "off"
}
}
],
"ignorePatterns": ["dist", "node_modules", "coverage"]
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+3
View File
@@ -0,0 +1,3 @@
{
"packages": ["npm:@spences10/pi-lsp@0.0.47"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"connor4312.nodejs-testing",
"oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview",
"sandy081.todotasks",
"EditorConfig.EditorConfig"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug current test file",
"runtimeExecutable": "node",
"runtimeArgs": ["--test", "--strip-types"],
"args": ["${file}"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"nodejs-testing.extensions": [
{
"extensions": ["mjs", "cjs", "js"],
"parameters": []
},
{
"extensions": ["ts"],
"parameters": ["--strip-types"]
}
],
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[mdx]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[yaml]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
+72
View File
@@ -0,0 +1,72 @@
# AGENTS.md
Machine entry point for AI coding agents working in this repo. The authoritative
guidance for humans lives in [CONTRIBUTING.md](./CONTRIBUTING.md) and
[README.md](./README.md); this file only points at it and states the stable
first-action facts. Do not restate evolving prose here — it will drift.
## First action
- Project: pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. **When you are looking for a symbol, reach for the LSP before `rg`/`grep`** — `lsp_references` / `lsp_find_symbol` / `lsp_definition` / `lsp_document_symbols` are semantic and cross-file, so they see shadowing, imports and overloads that a text search cannot; use `lsp_hover` to read inferred types on generic-heavy code. Use `rg` for what the LSP cannot see — doc prose, string literals, config, task lists, file discovery — and reconcile the two sets before editing (symbols from the LSP, strings and prose from `rg`). It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong.
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **Touched a `ts`-tagged fence in `README.md` / `CONTRIBUTING.md`?** Run `npm run create:doc-tests` first: it regenerates the gitignored tests under `src/doc-test/__generated__/`, formats and type-checks them, so `verify` executes the documented example. CI runs it in an explicit step before `test:ci`; the fast local tiers do not. See [development/docs.md](./development/docs.md).
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/<category>.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes.
- **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch.
```sh
npm run test # while iterating (fast feedback)
npm run verify # definition of done: whole-project correctness, one shot
```
## Never do
Don't silence the type system to force a green run. As an agent these are forbidden:
- `// @ts-nocheck`, `// @ts-ignore`, `// @ts-expect-error`
- `// oxlint-disable` / `// oxlint-disable-next-line`
- editing `.oxlintrc.json` to silence a finding (e.g. turning `typescript/no-floating-promises` off)
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. Suppressions — a source `oxlint-disable` **or** a `.oxlintrc.json` entry — are a **human** last resort, not a tool for you. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
Never start a long-lived / blocking process such as `npm run watch`. It runs until a human stops it with Ctrl-C, so in an agent turn it hangs forever and floods the context with continuous output. Reach for a one-shot command instead — `npm run test` (or `npm run check`) — to get feedback.
## Backlog
`backlog.tasks` uses the vscode-todotasks format (not Markdown). A line ending in `:` is a project; every other line is a task. Status glyphs: `☐` open, `✔` done, `✘` cancelled; subtasks nest by indentation. Inline `@tags` carry metadata — `@done` / `@cancelled` mark completion, `@critical` / `@high` / `@low` / `@today` set priority. The `(…)` timestamp after `@done` is editor-generated: omit it when checking off by hand.
**Required coupling:** a `✔` line _must_ also carry `@done`, and a `✘` line _must_ carry `@cancelled`. The `sandy081.todotasks` extension treats the glyph as the completion signal, then unconditionally searches for the matching tag to decorate; a bare `✔`/`✘` with no tag makes it compute an illegal `Range` (negative character offset) that throws and kills all highlighting/decoration for the document. A `☐` may stand alone. So check off by hand as `✔ … @done` (optionally `@done (timestamp)`), never a lone `✔`.
### Working on tasks
Every task — with or without subtasks — goes through the complete branching model:
- Create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work with commits (each subtask gets one or more), then present a concise handover for the user to review. Use this fixed shape:
```md
## Handover — <branch>
**Implemented:** <what was built, and how>
**Judgement calls:** <where the task was unclear, and what you assumed>
**Known problems:** <open issues, caveats, follow-ups>
```
Once the user has no further objections, merge back: `npm run create:finish` (on the branch — it merges `--no-ff`, runs `npm run verify`, and deletes the branch). The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
Follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) throughout.
## Read these
- [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) — the constraints the linters don't catch; CI/review bounce these. **The most important section.**
- [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven) — write the `expectTypeOf` (type) before the `assert` (red); the types are the feature.
- [CONTRIBUTING.md § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong.
- [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72.
- [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI).
- [development/](./development/README.md) — the decisions, rejected alternatives and known issues behind the rules; the “why” that CONTRIBUTING.md links to. Read the relevant file before changing an area.
- [development/tooling.md](./development/tooling.md) — the rationale behind each tool choice; read before changing tooling.
- [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project).
+148
View File
@@ -0,0 +1,148 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
## [Unreleased]
## [0.9.0] - 2026-09-29
- 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,
oxlint-tsgolint 7.0.2003, cspell 10.3.5 and the rest within their existing
ranges; no rule or format fallout
- prune the completed items from the backlog; their rationale already lives in
the README, `development/` and the released changelog entries
## [0.8.2] - 2026-09-25
- write the README's Synopsis and Examples sections
- document the public API in the README
- add TSDoc to the four public matcher factories
- document alternatives
## [0.8.1] - 2026-09-23
- gate CI at 100% coverage: `test:ci` runs c8 with `--all --100` over `src/`,
and a test loads the `index.ts` barrel so it is measured
- pin the duplicate-tag union collapse (members sharing a tag dispatch through
one handler) with tests
## [0.8.0] - 2026-09-23
- reject a universe that mixes a literal with a broad type (e.g.
`"a" | \`x-${number}\``), which the finite-literal gate let through
- narrow the tagged-union matcher's handler parameters for a union-valued or
optional discriminant, and its fallback to the unhandled tags, instead of
passing `never`
## [0.7.1] - 2026-09-23
- upgrade dependencies
## [0.7.0] - 2026-09-23
- reject broad universes (`string`, `number`, template literals) and
value/stringification collisions (`true | "true"`, `1 | "1"`) at the factory
- type handler parameters by the matched member, so a
standalone `"true"` universe is typed `"true"` rather than `true`
## [0.6.0] - 2026-09-22
- add `getTaggedUnionMatcher` / `getTaggedUnionMatcherW` for discriminated unions
- rename the primitive matcher to primitive-union: `getMatcher` /
`getMatcherW` → `getPrimitiveUnionMatcher` / `getPrimitiveUnionMatcherW`, and
`src/primitive.ts` / `src/primitive.test.ts` → `src/primitive-union.*`
## [0.5.0] - 2026-09-21
- reject a fallback when the handler map already covers the universe
- allow boolean, null and undefined in the matcher universe
## [0.4.0] - 2026-09-20
- move the `_` fallback out of handlers
## [0.3.0] - 2026-09-18
- condensed primitive matcher factories down to 2 from formerly 4
- house shared test helpers under `src/util/__tests__/`
## [0.2.0] - 2026-09-16
- allow ternaries and lowercase comments in oxlint
- switch `src/primitive.ts` prose from block comments to line comments
- remove the template's `match`/`P` example modules and their documentation, and point `src/index.ts` at the primitive matchers
## [0.1.8] - 2026-09-16
- upgrade dependencies
- ignore `@types/node` in `maintain:outdated` (misleading `latest` dist-tag)
- require extremely concise prose in `development/`
## [0.1.7] - 2026-09-16
- require a short summary under `[Unreleased]` in the changelog before a branch is finished
- let `create:branch` start from a `main` that is ahead of its upstream, so a finished merge no longer blocks the next branch until it is pushed
## [0.1.6] - 2026-09-15
- restructure the documentation: README.md for users, CONTRIBUTING.md for contributors, and development/ for the decisions, rejected alternatives and known issues
- document the decisions and known issues for CI, tooling, testing, publishing and the workflow
## [0.1.5] - 2026-09-15
- improve CI configuration
## [0.1.4] - 2026-09-14
- fix CI to node from custom image
- upgrade dependencies
## [0.1.3] - 2026-09-14
- change to custom image for CI
## [0.1.2] - 2026-09-14
- upgrade dependencies
## [0.1.1] - 2026-09-14
- upgrade dependencies
## [0.1.0] - 2026-09-14
- basic setup
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.9.0...main
[0.9.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.3...0.9.0
[0.8.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.2...0.8.3
[0.8.2]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.1...0.8.2
[0.8.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.8.0...0.8.1
[0.8.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.1...0.8.0
[0.7.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.7.0...0.7.1
[0.7.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.6.0...0.7.0
[0.6.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.5.0...0.6.0
[0.5.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.4.0...0.5.0
[0.4.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.3.0...0.4.0
[0.3.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.2.0...0.3.0
[0.2.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.8...0.2.0
[0.1.8]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.7...0.1.8
[0.1.7]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.6...0.1.7
[0.1.6]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.5...0.1.6
[0.1.5]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.4...0.1.5
[0.1.4]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.3...0.1.4
[0.1.3]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.2...0.1.3
[0.1.2]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.1...0.1.2
[0.1.1]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/0.1.0...0.1.1
[0.1.0]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/compare/20308c5a6d8cccfb09b02ac2ebebd8055e91cd11...0.1.0
+266
View File
@@ -0,0 +1,266 @@
# Contributing
This document is for maintainers and contributors working on the project
itself. End-user documentation is in [README.md](./README.md). The reasons
behind the rules here — the decisions, rejected alternatives, and known issues —
live in [development/](./development/README.md). The machine entry point for AI
coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for
the rules so agents and humans don't diverge.
## Setup
1. Clone the repository.
2. Install Node.js >= 26 — see [.node-version](./.node-version); the exact pinned
version is what CI and the runner image use.
3. `npm ci`.
4. `npm run setup` — the one-time clone configuration (currently registers the
commit-message template).
## Development commands
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Compat (CI-only, manual locally):** `npm run test:compat` — type-check the
suite + a consumer fixture against the minimum supported TypeScript; needs a
built `dist/` and network access (`npx`). Never part of a hook or
`check`/`verify`
- **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
in the prose docs into executed, gitignored tests
- **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
## Feedback tiers
The tools are organized into a feedback ladder. Each tier catches different
things at different costs; the rule of thumb is "earlier tiers fire more often,
faster tiers catch less, slower tiers are more thorough":
| Tier | When | What it runs | Time |
| -------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| `npm run watch` | manual | `watch:test` — re-runs tests on file save | ~0.1s |
| Pre-commit (auto) | on stage | tsc + oxlint + oxfmt + cspell (staged files only) | ~1.3s |
| Pre-push (auto) | on push | `npm test` (full tsc + unit tests) | ~3.5s |
| `npm run check` | manual | Correctness gates: tsc + oxlint + oxfmt + cspell (whole project) | ~3s |
| `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s |
| `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) | in CI only | `compat` job — `test:compat` over the built `dist/`; never a local tier (run by hand) — 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 |
Before pushing, run `npm run verify` — the one-shot correctness gate. Run
`npm run maintain` only on a maintenance / update-deps branch. Why the splits
are where they are: [development/workflow.md § Feedback tiers](./development/workflow.md#feedback-tiers).
## Testing discipline (type-driven)
For this library the types _are_ the feature, so development is **type-driven**:
the compile-time expectation is written before the runtime assertion, and both
before the implementation. The loop is **type → red → green → refactor**:
1. **Type** — write the compile-time expectation first
(`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on
the _type_. The type error is the spec you want to hit before the runtime
logic exists.
2. **Red** — add the matching runtime assertion (`assert.*`) so
`npm run test:unit` now fails on behavior.
3. **Green** — implement in `src/*.ts` until both the type check and the test
pass.
4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate.
Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together
— including the expectations inside handler bodies, which pair with an
assertion on the value dispatch passed, not only the type it inferred (see
[development/testing.md § Handler arguments](./development/testing.md#handler-arguments)).
The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
helper, `src/primitive-union.test.ts` for the matcher's popup) are the
exception —
the language server, not the type system, is the oracle (see
[development/testing.md § Autocomplete](./development/testing.md#autocomplete)).
Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks
separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher
factory), `// Act` exercises the subject once from them (not a second
throwaway call), `// Assert` holds every check — type expectations first,
runtime assertions last; an empty block drops its label (see
[development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)).
Type-first is enforced structurally: `npm test` runs `check:tsc` before the
test runner, so a wrong type can never be papered over by a passing assertion.
Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the
types, never suppress the checks you can't make pass. Full rationale:
[development/testing.md](./development/testing.md).
## Documentation examples
Every `ts`-tagged fence in `README.md` / `CONTRIBUTING.md` is compiled into an
executed test, so a documented example cannot drift from the API. Describe each
fence with the paragraph directly above it (that text becomes the test title),
and keep its library import self-contained;
`npm run create:doc-tests` regenerates, formats and type-checks the tests under
`src/doc-test/__generated__/`. CI runs it in an explicit step before `test:ci`,
so run it yourself before `npm run verify` when you touched a fence. Why:
[development/docs.md](./development/docs.md).
## Code style and formatting
`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules).
`npm run fix` resolves the fixable issues; `npm run check` verifies without
writing. Suppressions must be fixed at the root — do not add `oxlint-disable`
directives or `as` casts to force a green run (see
[AGENTS.md § Never do](./AGENTS.md#never-do)).
Suggested VSCode extensions are in
[.vscode/extensions.json](./.vscode/extensions.json); the project's formatter
and linter are wired up there. Toolchain decisions:
[development/tooling.md](./development/tooling.md).
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is
[commit-message-template](./commit-message-template); `npm run setup`
(or `npm run setup:git-commit-message`) registers it as git's
`commit.template`. Examples and rationale:
[development/workflow.md § Commit messages](./development/workflow.md#commit-messages).
## Script prefix convention
Script names in `package.json` use a prefix that signals _when_ the script is
intended to run. A `<prefix>:<name>` script is implicitly aggregated by a
`<prefix>` script (if one exists) and run by the corresponding lefthook hook or
CI step. Pick the prefix that matches the script's lifecycle:
- `create:*` — front doors of the repo's own workflow; these produce or mutate
workflow artifacts (git state, generated doc-tests) rather than the
hand-written source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half
(maintainer-only), and `create:doc-tests` regenerates the compiled prose
examples. No bare `create` aggregator on purpose.
- `check:*` — read-only verification; never modifies files. Aggregated by
`npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
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/`; `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
and/or network-bound, so never a correctness gate. Aggregated by
`npm run maintain`.
- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than
the source, so it needs a fresh build.
- `setup:*` — one-time configuration of a fresh clone; mutates the local
environment rather than the repo source, so it is never part of a hook or CI
step. Aggregated by `npm run setup`, run once after cloning.
A new script must reuse an existing prefix. If none fits, that's a signal the
script doesn't belong in the pipeline — not a reason to invent a new prefix. If
it genuinely does belong, add the prefix to this list in the same commit as its
first member; an undocumented prefix becomes invisible and quietly accrues
members. Why `create:` exists, the rejected names, and the design of the bare
scripts: [development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention).
## Rules the tools don't enforce
CI and review will bounce these even though `npm run check` and the linters
don't catch them. They're the high-frequency things a contributor (or an agent)
reaches for by default:
- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types`
only resolves the `.ts` form at test time; "pre-fixing" an import to `.js`
breaks the inner loop. (why:
[development/tooling.md](./development/tooling.md#source-imports-use-ts-extensions))
- **A new `npm run` script must reuse an existing prefix.** See
[Script prefix convention](#script-prefix-convention).
- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The
trade-off must sit next to the code it silences. This is a _human_ last-resort
convention; agents must not add one, nor edit `.oxlintrc.json` to silence a
finding (e.g. `typescript/no-floating-promises`) — see
[AGENTS.md § Never do](./AGENTS.md#never-do). (why:
[development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code))
- **Don't put slow / network / whole-project scans in `check` or pre-commit.**
Advisory scans are not correctness gates; they belong under `maintain:`. (why:
[development/workflow.md](./development/workflow.md#feedback-tiers))
- **New work starts with `npm run create:branch`, never a hand-written
`git switch -c` / `git checkout -b`.** The command carries the branch
precondition; branching around it skips the clean-tree, current-`main` and
green-baseline checks, and the skip is invisible until a failure can no longer
be attributed. (why:
[development/workflow.md](./development/workflow.md#branching-model))
- **Work is merged back with `npm run create:finish`, never a hand-written
`git merge`.** The command carries the merge-side preconditions (clean tree,
current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify`
after the merge, so a merge cannot land unverified. (why:
[development/workflow.md](./development/workflow.md#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw`
don't go in `check`.** (why:
[development/publishing.md](./development/publishing.md#ci-only-publishing))
- **A branch ends with a changelog note:** before `npm run create:finish`,
summarize the work under `[Unreleased]` in [CHANGELOG.md](./CHANGELOG.md).
(why:
[development/workflow.md](./development/workflow.md#changelog-notes))
- **A decision or its rationale belongs in `development/`, not here.** This file
holds the actionable rule; `development/<category>.md` holds why, the rejected
alternatives and the known issues. When you change a rule, update its category
file in the same commit and cross-link the two. (why:
[development/README.md](./development/README.md))
- **Prose in `development/` is extremely concise.** When adding or changing a
decision, write fragments if needed — sacrifice grammar for concision. (why:
[development/README.md § Decision blocks](./development/README.md#decision-blocks))
## Branching model
**GitHub Flow (single-developer).** Every change — feature, fix, refactor —
branches off `main` and is merged back via a local commit. There is no pull
request workflow on Gitea yet.
- **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>`
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses,
without changing anything, unless the working tree is clean, no
merge/rebase/cherry-pick is in progress, `main` is not behind its upstream
(a local merge not yet pushed is fine — the push belongs to `create:release`),
and `npm run test` is green on `main`. The prefix is _your_ call, inferred from
the task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It re-asserts the same
preconditions, merges `--no-ff`, runs `npm run verify`, and deletes the branch
only after the merge is green. The push is left to `create:release`, so the
merge stays local and reviewable.
- CI runs on every push to `main` — see [Feedback tiers](#feedback-tiers) and
[.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a
release; see [Publishing](#publishing).
Full rationale, including the front-door decisions:
[development/workflow.md § Branching model](./development/workflow.md#branching-model).
## Submitting changes
There is no pull request workflow on Gitea yet, so a contribution is submitted
as a branch that is merged locally:
1. `npm run create:branch -- <prefix>/<desc>`.
2. Commit your work (one or more commits, per the tests and style rules above).
3. `npm run verify` — the definition of done.
4. Add a changelog note under `[Unreleased]` (see
[Rules the tools don't enforce](#rules-the-tools-dont-enforce)).
5. `npm run create:finish` to merge the branch into `main` and verify the
result.
6. Present a handover for review. Once there are no further objections, the
maintainer pushes.
When the project is promoted to GitHub, this step becomes a normal pull request
against `main`.
## Publishing
Publishing is maintainer-only and CI-only. See
[development/publishing.md](./development/publishing.md).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+339
View File
@@ -1 +1,340 @@
# tiny-pattern-ts
Exhaustive, type-safe pattern matching for TypeScript.
## Synopsis
```ts
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
// 1. We have a union type
type Contact =
| { kind: "email"; address: string }
| { kind: "phone"; number: string }
| { kind: "messenger"; username: string };
// 2. Create a matcher providing the discriminant property
const matchContact = getTaggedUnionMatcher<Contact>()("kind");
// 3. Define handlers for each branch of the union
const formatContact = matchContact({
email: (e) => `MAIL: ${e.address}`,
phone: (p) => `PHONE: ${p.number}`,
messenger: (m) => `MESSENGER: @${m.username}`,
});
// 4. Call the matcher with a value
const mailOutput = formatContact({ kind: "email", address: "ada@example.com" });
assert.equal(mailOutput, "MAIL: ada@example.com");
const phoneOutput = formatContact({ kind: "phone", number: "+1 555 0100" });
assert.equal(phoneOutput, "PHONE: +1 555 0100");
```
## Description
`tiny-pattern-ts` is a pattern-matching library for TypeScript.
The main goal of `tiny-pattern-ts` is to make pattern matching type-safe with a
lean syntax. This is accomplished by being exhaustive and passing typed
parameters per branch to the handlers — supported by an outstanding
autocomplete and a tiny footprint. The matchers are data last and pipe-friendly:
build the handler map once, then apply the resulting matcher to values
(`match(value)`, or `pipe(value, match)`).
See [development/library.md](./development/library.md) for the design decisions
and [Caveats](#caveats) for the limits.
## Installation
```sh
npm install tiny-pattern-ts
```
## Requirements
- **Node.js >= 26** (`engines` field; pinned via `.node-version`).
- **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
A few real-world recipes. Each binds the handler-map function once and reuses
it, so the matcher is allocated a single time.
### Dispatch on a primitive union
A result code is itself a finite union, so `getPrimitiveUnionMatcher` keys a
handler on each member:
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
type ResultCode = "ok" | "created" | "no-content";
const toStatus = getPrimitiveUnionMatcher<ResultCode>()({
ok: () => 200,
created: () => 201,
"no-content": () => 204,
});
assert.equal(toStatus("ok"), 200);
assert.equal(toStatus("created"), 201);
assert.equal(toStatus("no-content"), 204);
```
### Leave cases to a fallback
Pass a fallback as the second argument to handle only part of the universe; it
receives the members the map leaves uncovered — here the parameter is
`"deprecated" | "gateway-timeout"`:
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
type Status = "active" | "beta" | "deprecated" | "gateway-timeout";
const rollout = getPrimitiveUnionMatcher<Status>()(
{ active: () => "enabled", beta: () => "enabled" },
(status) => `blocked (${status})`,
);
assert.equal(rollout("active"), "enabled");
assert.equal(rollout("deprecated"), "blocked (deprecated)");
```
### Dispatch on a property union
The value does not have to be the union itself. When a single property carries a
finite union, the tagged-union matcher keys on it and narrows the whole record to
the selected value:
```ts
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
interface Invoice {
readonly currency: "eur" | "usd" | "jpy";
readonly amount: number;
}
const matchCurrency = getTaggedUnionMatcher<Invoice>()("currency");
const symbolOf = matchCurrency({
eur: (i) => `€${i.amount.toFixed(2)}`,
usd: (i) => `$${i.amount.toFixed(2)}`,
jpy: (i) => `¥${i.amount.toFixed(0)}`,
});
assert.equal(symbolOf({ currency: "usd", amount: 12.5 }), "$12.50");
assert.equal(symbolOf({ currency: "jpy", amount: 900 }), "¥900");
```
### Widen the return type
When the handlers return different types, reach for the widening `W` variant: the
matcher's return is their union rather than one common type — here
`string | string[] | undefined`:
```ts
import { getPrimitiveUnionMatcherW } from "tiny-pattern-ts";
type Field = "name" | "tags" | "note";
const parse = getPrimitiveUnionMatcherW<Field>()({
name: () => "Ada",
tags: () => ["admin", "beta"],
note: () => undefined,
});
assert.equal(parse("name"), "Ada");
assert.deepEqual(parse("tags"), ["admin", "beta"]);
assert.equal(parse("note"), undefined);
```
## API
The package exports four factories. Two axes pick one:
- **Universe** — a _primitive-union_ matcher matches a value that is itself a
finite union (`"yes" | "no"`); a _tagged-union_ matcher matches an object
discriminated by a property (`{ kind: … }`).
- **Return** — the _strict_ variant gives every handler one common return type
`R`; the _widening_ variant (`W`) widens the return value to the union of the
handler returns.
| Factory | Use when | Return |
| -------------------------------- | ------------------------------------------------------------------------- | --------------------- |
| `getPrimitiveUnionMatcher<T>()` | the value is the union and all handlers return the same type | one common `R` |
| `getPrimitiveUnionMatcherW<T>()` | the value is the union and handlers return different types | union of the handlers |
| `getTaggedUnionMatcher<T>()` | the value is a discriminated object and all handlers return the same type | one common `R` |
| `getTaggedUnionMatcherW<T>()` | the value is a discriminated object and handlers return different types | union of the handlers |
Bind the function that takes the handler map to a `match…` variable once and
reuse it; the [Examples](#examples) do this, so the builder is allocated once.
### Primitive-union matchers
`getPrimitiveUnionMatcher<T>()` takes the finite universe `T` and returns a
builder. Calling the builder with a handler map keyed by `T`'s members returns a
matcher: a function from `T` to the common return type.
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
const reply = matchAnswer({
yes: () => "agreed",
no: () => "declined",
});
const answer = reply("yes");
assert.equal(answer, "agreed");
```
Add a fallback as the second argument to leave members unhandled; the fallback
receives the remainder:
```ts
import { getPrimitiveUnionMatcher } from "tiny-pattern-ts";
const matchLabel = getPrimitiveUnionMatcher<"yes" | "no" | "maybe">();
const label = matchLabel(
{ yes: () => "agreed", no: () => "declined" },
(other) => `not sure: ${other}`, // other: "maybe"
);
const answer = label("yes");
assert.equal(answer, "agreed");
const fallback = label("maybe");
assert.equal(fallback, "not sure: maybe");
```
`getPrimitiveUnionMatcherW` is the same builder, but the matcher's return type
is the union of the handler return types rather than one common `R`.
### Tagged-union matchers
`getTaggedUnionMatcher<T>()` takes a discriminated union `T`. The returned
function takes the discriminant property's name and returns the handler-map
builder, keyed by that property's tags.
```ts
import { getTaggedUnionMatcher } from "tiny-pattern-ts";
type Shape =
{ kind: "circle"; radius: number } | { kind: "square"; side: number };
const matchShape = getTaggedUnionMatcher<Shape>()("kind");
const area = matchShape({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const circleArea = area({ kind: "circle", radius: 2 });
assert.equal(circleArea, Math.PI * 4);
const squareArea = area({ kind: "square", side: 3 });
assert.equal(squareArea, 9);
```
`getTaggedUnionMatcherW` is the widening counterpart, exactly as in the
primitive-union pair. The discriminant key is restricted to properties whose
values are tags; see [Caveats](#caveats) for the supported tags and the
`boolean` / `null` / `undefined` key projection.
### The universe
The primitive universe `T` must be a finite union of literals with no
value/stringification collision. A broad member (`string`, `number`, a template
literal) or a colliding pair (`true | "true"`, `1 | "1"`) is rejected at the
factory. The reasons and the rejected alternatives are in
[development/library.md](./development/library.md).
## Caveats
- **Only finite universes are supported.** The factory must be given a finite
union of literals; `string`, `number` and template literals are rejected. This
is what lets the exhaustive overload be proven, so the runtime `dispatch`
throw stays unreachable through the typed API.
- **A value and its stringification must not both be present.** Object keys
stringify, so a universe containing both a member and the string it
stringifies to — `1 | "1"`, `true | "true"`, `null | "null"` — is rejected at
the factory. Either form alone is fine, and one value's string form may
coexist with a _different_ value's bare form (`"true" | false`).
- **`symbol` and `bigint` are not supported.** A `symbol` brand is a
compile-time phantom with nothing to match at runtime, and a `bigint` is not a
valid property key; neither satisfies the matcher's universe constraint.
- **`NaN` and `-0` cannot be matched specifically.** They have no literal type,
so both stay part of `number`.
### Why open universes are rejected
An open universe — one carrying a broad member, as in
`type Units = "s" | "ms" | "min" | (string & {})` — is not a dispatch concern.
If the values arrive from outside the program, parse them at the boundary down
to a finite union and match the narrowed result; the openness never reaches the
matcher. If the domain is genuinely extensible, the right shape is a runtime
`Map` of handlers, where "no handler" is a lookup, not a pattern. Either way an
open matcher would abandon the one guarantee this library exists to give —
provable exhaustiveness — to automate what a `switch` and a default arm already
cover. The type-level cost of supporting open universes is recorded in
[development/library.md](./development/library.md#supported-universes).
## Alternatives
### [`ts-pattern`](https://github.com/gvergnaud/ts-pattern)
- is the full structural matcher — nested and partial patterns, guards, unions,
captures — exhausting at `.exhaustive()`
- has a fluent `.with(…)` chain that is heavy on syntax; tiny-pattern-ts is one
handler map
- is about 2 kB minified and gzipped; tiny-pattern-ts is 0.2 kB
- reach for it when you need a feature tiny-pattern-ts does not cover
### [Effect's `Match`](https://effect.website/docs/code-style/pattern-matching/)
- is the same piped matcher (`Match.type` / `Match.when` / `Match.exhaustive`),
but only as part of the `effect` ecosystem
- tiny-pattern-ts is standalone: no runtime dependency to buy into
### [`match-iz`](https://github.com/shuckster/match-iz)
- expresses patterns in the TC39 proposal's style, deciding each case at runtime
- is written in JavaScript with hand-maintained declarations, so its types do
not prove the cases exhaustive
- tiny-pattern-ts does: exhaustiveness is a compile-time guarantee, not an
`otherwise` fallback
### plain `switch` (baseline)
- is the zero-dependency baseline — pair it with
[`eslint-plugin-strict-pattern-matching`](https://www.npmjs.com/package/eslint-plugin-strict-pattern-matching)
for exhaustiveness
- is a statement, not an expression, so it cannot produce a value directly
- leaves the `never` guard to you; tiny-pattern-ts is an expression and does not
need one
The [TC39 pattern-matching proposal](https://github.com/tc39/proposal-pattern-matching)
is still stage 1, so userland libraries remain the only option today.
## License
MIT © 2026 tmu. See [LICENSE](./LICENSE).
## Contributing
Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the
reasons behind the project's decisions, rejected alternatives, and known issues
live in [development/](./development/README.md). AI coding agents start at
[AGENTS.md](./AGENTS.md).
+25
View File
@@ -0,0 +1,25 @@
Tasks
Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
---
Setup:
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @low
Documentation:
☐ Create `examples/` directory with runnable snippets
Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
→ design: no deploy step in CI; the webserver just exposes the shared directory (decided over Gitea Pages / Codecov — neither confirmed available/ wanted)
☐ serve docs over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving docs (reuse existing reverse proxy)
☐ CI writes docs to a shared volume keyed by project + tag (e.g. `/docs/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/docs/<repo>/<tag>/index.html` in the browser
☐ serve landing page over self hosted server @low
☐ Add a minimal dir-listing webserver to the gitea docker setup for serving landing page (reuse existing reverse proxy)
☐ CI writes landing page to a shared volume keyed by project + tag (e.g. `/landing/tiny-pattern-ts/<tag>/`)
☐ Browse to `…/tiny-pattern-ts/index.html` in the browser
✔ Add testing with the TypeScript floor in CI @done
+23
View File
@@ -0,0 +1,23 @@
# If applied, this commit will... (Max 50 char)
# Explain why this change is being made (Max 72 Char) [WHAT and WHY vs HOW]
# Provide links or keys to any relevant tickets, articles or other resources
Resolves #...
# --- COMMIT END ---
# Remember to
# Use the imperative mood in the subject line
# Capitalize the subject line
# Do not end the subject line with a period
# Separate subject from body with a blank line
# Use the body to explain what and why vs. how
# Can use multiple lines with "-" for bullet points in body
+89
View File
@@ -0,0 +1,89 @@
// 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 rejects `any`, so the assertions prove the emitted types are real.
// Every handler *parameter* is asserted too, not just the matcher: the handler's
// member type comes from the `Member` inversion in the declarations, and a wrong
// or `any` parameter would otherwise slip through, since the matcher's own
// `(shape: T) => R` type is built from `T` and the handler returns. 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<ResultCode>()({
ok: (s) => {
expectTypeOf(s).toEqualTypeOf<"ok">();
return "OK";
},
created: (s) => {
expectTypeOf(s).toEqualTypeOf<"created">();
return "CREATED";
},
});
expectTypeOf(toStatus).toEqualTypeOf<(shape: ResultCode) => string>();
// The widening twin keeps each handler's own return type in the union.
const toStatusW = getPrimitiveUnionMatcherW<ResultCode>()(
{
ok: (s) => {
expectTypeOf(s).toEqualTypeOf<"ok">();
return "OK" as const;
},
},
(rest) => {
expectTypeOf(rest).toEqualTypeOf<"created">();
return "CREATED" as const;
},
);
expectTypeOf(toStatusW).toEqualTypeOf<
(shape: ResultCode) => "OK" | "CREATED"
>();
type Contact =
| { kind: "email"; address: string }
| { kind: "phone"; number: string };
const format = getTaggedUnionMatcher<Contact>()("kind")({
email: (e) => {
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
return e.address;
},
phone: (p) => {
expectTypeOf(p).toEqualTypeOf<{ kind: "phone"; number: string }>();
return p.number;
},
});
expectTypeOf(format).toEqualTypeOf<(shape: Contact) => string>();
const formatW = getTaggedUnionMatcherW<Contact>()("kind")(
{
email: (e) => {
expectTypeOf(e).toEqualTypeOf<{ kind: "email"; address: string }>();
return e.address;
},
},
(rest) => {
expectTypeOf(rest).toEqualTypeOf<{ kind: "phone"; number: string }>();
return rest.number;
},
);
expectTypeOf(formatW).toEqualTypeOf<(shape: Contact) => string>();
export { format, formatW, toStatus, toStatusW };
+15
View File
@@ -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"]
}
+48 -2
View File
@@ -1,6 +1,52 @@
{
"version": "0.2",
"language": "en",
"words": ["tslib", "typefest", "lefthook"],
"ignorePaths": ["dist", "node_modules", "public", "coverage", "*.svg"]
"words": [
"lefthook",
"oxlint",
"oxfmt",
"oxc",
"nodenext",
"oxfmtrc",
"oxlintrc",
"EDITMSG",
"typescriptteam",
"gitmoji",
"dbaeumer",
"msvc",
"publint",
"attw",
"arethetypeswrong",
"knip",
"tsgolint",
"tsgo",
"tsserver",
"gitea",
"lipanski",
"pubv",
"knope",
"runwisp",
"glab",
"hostedtoolcache",
"nodebase",
"frontends",
"catthehacker",
"nsnull",
"dedup",
"dedupe",
"repoint",
"postversion",
"prebuild",
"Zilla",
"kacl",
"bestikk",
"silverwind",
"idris",
"todotasks",
"connor",
"injective",
"injectivity",
"userland"
],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
}
+82
View File
@@ -0,0 +1,82 @@
# Development documentation
Why this project works the way it does: the decisions, what was rejected, and
the shortcomings and known issues we carry. Written for maintainers and
contributors.
The actionable rules — setup, running, testing, submitting — live in
[CONTRIBUTING.md](../CONTRIBUTING.md). **Each fact is written once**: the rule
there, the reason here; neither restates the other, and where a fact is useful
in both they link. Read the relevant file before changing an area, and when a
rule changes update its rationale here in the same commit.
User-facing documentation is [README.md](../README.md). `docs/` is deliberately
unused: that name is reserved for the future user documentation site, and
deploying it is out of scope. These files are not part of that site.
## Layout
One file per category:
| File | Covers |
| -------------------------------- | ----------------------------------------------------------------------- |
| [library.md](./library.md) | Public API design, the type-level contract, and its limitations |
| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [testing.md](./testing.md) | Test strategy and type-driven development |
| [docs.md](./docs.md) | Validating the Markdown code fences in the prose docs |
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing |
We start with one file per category so each area stays small enough to hold in
mind; a category that outgrows it becomes a folder with an index, and the links
in CONTRIBUTING.md and README.md point at the category, not a single decision.
## Decision blocks
Record every non-obvious choice as a block in the relevant category file:
```md
## Runner image
#### Decision (2026-09)
Bake Node into the CI job image at the setup-node tool-cache layout instead
of downloading per job.
#### Why
- ...
#### Rejected
- Gitea Pages / per-job download
- force-pull
#### Known issue
- a Dockerfile-only change re-pushed under an unchanged tag is invisible to the runner
- recover with `docker rmi <image>`
```
- The date is the month the decision was made, not when the file was edited —
the anchor for "current" versus "was current once".
- `Rejected` stops the project re-litigating the same alternatives; an empty one
usually means they were never written down.
- `Known issue` is where shortcomings live. A caveat not tied to one decision
goes under a `## Known issues` section at the end of the file.
- Replace a superseded decision in place rather than archiving it; git history
is the archive.
- Terse is the point: humans skim and agents imitate the style already in the
file, so verbosity compounds edit over edit. Grammar loses to density here on
purpose.
## Adding to these docs
1. Pick the category: `library`, `workflow`, `tooling`, `testing`, `ci`,
`publishing`.
2. Add or update a decision block; keep existing text unless the decision
changed.
3. If an actionable rule changes, update
[CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link.
Never change a rule there without updating its rationale here.
+242
View File
@@ -0,0 +1,242 @@
# CI
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) is the source of truth for
the job graph; this file records why it is shaped the way it is.
## Pipeline
- **`build`** (push to `main` / tag) — build + correctness + packaging.
- **`compat`** (CI only) — type-check the suite and a consumer fixture against
the minimum supported TypeScript; consumes `build`'s `dist/` and gates
`publish`. It has **no local tier**: it never runs in a hook or in
`check` / `verify` / `test:ci`; run it by hand with
`npm run build && npm run test:compat`. 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`,
then the Gitea release page and `npm publish` (see
[publishing.md](./publishing.md)).
- **`release-gate`** — on a `:rocket: Release x.y.z` commit it skips
`build`/`maintain`, because `create:release` pushes the tag for the same commit
right after and the tag run is authoritative. It uses no Node and stays on the
default image.
## Runner image
#### Decision (2026-09)
`build` / `maintain` / `publish` run in `gitea.e1nsnull.de/tmu/act-ci:<version>`
([docker/Dockerfile](../docker/Dockerfile)): the default act image with Node
overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes.
#### Why
- No job pays the ~50 MB Node fetch, because the probe hits the baked entry.
- The tag must equal the exact [.node-version](../.node-version) pin, and the
image is rebuilt only as part of a Node bump — there is no other trigger.
- Building needs a docker daemon and registry credentials, so it belongs to no
feedback tier. That is why it is **not** an `npm run` script: no
[prefix](./workflow.md#script-prefix-convention) fits, and that is the signal.
#### Rejected
- Downloading Node in every job — the ~50 MB fetch was the original problem.
- Caching Proxy (Squid or similar) — adds complexity to global setup
- Mounting the tool cache - No invalidation will fill the cache with stale versions
## Bumping Node
Bumping Node is one coordinated change, committed as a unit:
1. Edit [.node-version](../.node-version) to the exact `x.y.z` — floats like `26`
resolve to the latest patch at runtime and bust the baked entry, so
[scripts/runner-image.sh](../scripts/runner-image.sh) refuses them.
2. `docker login gitea.e1nsnull.de` (user + package/access token), then
`./scripts/runner-image.sh --push`, which reads the version and pushes
`<IMAGE_REPO>:<version>`.
3. Repoint the three `container.image` tags in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to that version.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the
per-job download.
## Image invariants
For the `setup-node` probe to hit, two things must hold — both easy to break:
### The `x64.complete` marker
#### Decision (2026-09)
Bake a `<version>/<arch>.complete` marker next to the Node directory.
#### Why
- `actions/tool-cache` accepts a cached tool only when
`<version>/<arch>.complete` exists beside it (`tc.find()` checks). A bare
`node/<version>/x64/` is ignored and the download happens anyway. See the
comment in [docker/Dockerfile](../docker/Dockerfile).
### Tag freshness, with force-pull deliberately off
#### Decision (2026-09)
Leave `act_runner`'s `force_pull` disabled.
#### Why
- The tag encodes only the Node version, and the image is rebuilt only when that
changes — so the normal flow always yields a new tag and the runner pulls it.
- Forcing a pull re-pulls the image on every job for no benefit.
#### Rejected
- Enabling `force_pull`: it is acceptable to miss a runner-side image change,
and a `Dockerfile`-only change is not worth a per-job pull.
#### Known issue
- A `Dockerfile`-only change (like the marker above) re-pushed under an
unchanged tag is invisible to the runner, which keeps the old image while the
registry shows the new digest. Remove the stale tag on the runner host
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
force-pull.
## Coverage threshold
#### Decision (2026-09)
`npm run test:ci` fails below 100% statements / branches / functions / lines
across `src/**/*.ts` (`c8 --all --include "src/**/*.ts" --100`). The gate rides
the `build` job; `npm run verify` stays coverage-free.
#### Why
- The types are the feature, so an untested branch is a hole in the contract,
not a metric to trade off; 100% is the only threshold that means "no hole".
- `--all` counts a `src/` file no test imports. Without it c8 reports only the
files the suite happened to load, so a new untested module is invisible and
the threshold passes vacuously.
- The gate rides `test:ci`, which `build` already runs — no new job or step.
- `verify` stays fast and local; the slower coverage run is a CI-only tier (see
[workflow.md § Feedback tiers](./workflow.md#feedback-tiers)).
#### Rejected
- Per-file thresholds: a global 100% already forces every counted file to 100%.
- A `check:coverage` script: it would re-run the suite or read c8's temp dir,
and no `check:*` script runs tests.
- `--all` without `--include`: it would also sweep `scripts/`, which is not the
shipped surface.
#### Known issue
- `src/matcher-shared.ts` is types only, so its runtime image is empty; c8 still
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)). It is not in
`check`, `verify`, `test:ci` or any hook; locally it is run **by hand** 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`'s exact-equality assertions
are load-bearing: a bare compile would also pass if a declaration collapsed to
`any`; they 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)
Serve CI coverage from a shared directory on the runner, with no deploy step in
CI.
#### Why
- The webserver exposes the shared directory and the Gitea docker setup reuses
the existing reverse proxy — no upload artifact, no external service.
- Coverage is written to a shared volume keyed by project and tag (for example
`/docs/tiny-pattern-ts/<tag>/`).
#### Rejected
- Gitea Pages and Codecov: neither was confirmed available or wanted.
#### Known issue
- Coverage is served for tag pushes only; non-tag pushes (for example
`main/coverage`) are tracked separately.
[scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst`
sidecars next to text assets. The Gitea pages service (`static-web-server` with
`SERVER_COMPRESSION_STATIC=true`) serves the sidecar matching `Accept-Encoding`
and falls back to the original.
#### Decision (2026-09)
Precompress into sidecars rather than per request.
#### Why
- The assets are static and change only on deploy, so the work is paid once.
- Images, fonts and archives are already compressed; a sidecar would only grow
them, so only text extensions are emitted.
+61
View File
@@ -0,0 +1,61 @@
# Docs
Why the prose documentation is maintained the way it is. The actionable rules
are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records the rationale.
## Validating Markdown code fences
#### Decision (2026-11)
Compile every `ts / `typescript fence in the prose docs into a real
`node:test` case under `src/doc-test/__generated__/`, typechecked by a scoped
`tsc` project and executed by `node --test`.
#### Why
- A documented example is a promise about the API. Left unchecked it drifts the
moment a signature changes, and a reader copies broken code.
- The repo runs TypeScript 7, the native/Go compiler. It exposes **no legacy JS
compiler API** (`ts.createProgram`, `ts.transpileModule`, `ts.createSourceFile`
are all `undefined`; `Object.keys(require("typescript"))` is
`["version", "versionMajorMinor"]`). So `@typescript/vfs`, the type-aware
`eslint-plugin-markdown` and `docs-ts` / `@effect/docgen` cannot run here.
- `node --check` parses as JS and rejects valid TS type annotations, so it is not
a gate. The only faithful validator is the `tsc` **CLI**, which means emitting
real `.ts` files and letting the existing `check:tsc` / `node --test` pipeline
judge them.
#### Rejected
- **A packaged doc-test tool** (see above) — no usable compiler API on TS 7.
- **Embedding a typecheck in the generator** — duplicates the gate, and would
not exercise the repo's own resolution.
- **`node --check`** — wrong language level.
#### Known issue
- Scanned sources are hard-coded to `README.md` and `CONTRIBUTING.md`. A
`docs/` + `examples/` list is the natural extension; `development/` must never
be scanned (its fences are illustrative, not compilable).
- The generator hoists and merges leading imports, rewrites `tiny-pattern-ts` to
the `#test-tiny-pattern-ts` source alias, and rejects an example that imports
`node:assert` / `node:test` (the prelude already binds both). Titles are the
immediately preceding paragraph; a fence with no such paragraph is a fatal
error, which keeps every example described.
- `oxlint src/doc-test` reports "No files found" because the generated
`*.test.ts` are gitignored. That is cosmetic: the files are still typechecked
and run.
- The generated tests are `*.test.ts`, which c8's default excludes already keep
out of the `--100` gate. Do not add an `--exclude` for them: passing any
`--exclude` replaces the defaults, so every hand-written test file and
`__tests__/` helper re-enters coverage and the gate fails.
- Generated examples must not run under `c8`: an example could cover a line no
hand-written test reaches, so the coverage gate would pass on documentation
alone. `test:coverage` therefore runs c8 over the tracked tests only
(`git ls-files 'src/*.test.ts'` — the generated files are untracked), and
`test:doc` runs the examples in a separate process without c8. `test:ci`
chains the two, so correctness and coverage stay independent.
- The CI `build` job runs `npm run create:doc-tests` as an explicit step before
`npm run test:ci`, not a `pretest:ci` lifecycle hook: the hook hides the step
from the job log and makes `test:ci` behave differently under npm than when
run directly.
+311
View File
@@ -0,0 +1,311 @@
# Library design
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
The matchers are implemented in `src/primitive-union.ts` (`getPrimitiveUnionMatcher` /
`getPrimitiveUnionMatcherW`) and `src/tagged-union.ts` (`getTaggedUnionMatcher` /
`getTaggedUnionMatcherW`), re-exported from `src/index.ts`; the rest of `src/`
is implementation detail.
## Public surface
#### Decision (2026-09)
`src/index.ts` exports the four factories and nothing else. Every exported
function carries TSDoc; the builder types and the `matcher-shared.ts`
vocabulary stay internal.
#### Why
- The factories are the whole contract: a consumer calls one and never needs to
name the builder type it returns.
- The builder interfaces are already the inferred return types, so they travel
into the emitted `.d.ts` regardless. Exporting them would only make them
nameable while freezing the internal `Strict` / `Widening` overload split as
API.
- TSDoc travels into the emitted declarations, so editor hovers and the
published package document the API without a hand-written `.d.ts`.
#### Rejected
- **Exporting the builder types** (`PrimitiveUnionMatcher`, …). Nameable, but it
grows the surface for no call-site benefit and pins the `Strict` / `Widening`
split.
- **Exporting the `matcher-shared.ts` vocabulary** (`Matchable`, `UnaryFn`,
`PatternKey`, `Member`, `PatternReturns`, …). They appear in the public
signatures, but a consumer never needs to name them; exporting them would
freeze plumbing as API.
- **A hand-written `.d.ts` or a separate API document.** It would drift from the
implementation; TSDoc is generated from the source.
## Matcher shape
#### Decision (2026-09)
A factory takes the universe and returns a builder; the builder takes a handler
map and an optional fallback:
```ts
const matcher = getPrimitiveUnionMatcher<"a" | "b">()({ a: (s) => …, b: (s) => … });
const fallback = getPrimitiveUnionMatcher<"a" | "b" | "c">()({ a: (s) => … }, (s) => …);
```
Exhaustive or fallback is decided **at the call site**, by whether the second
argument is present. The fallback's parameter is the remainder
`Exclude<T, keyof Handled>`. Only the return-strictness axis remains, so there
are two factories:
- `getPrimitiveUnionMatcher` — one common `R`; the fallback must fit it;
- `getPrimitiveUnionMatcherW` — the union `PatternReturns<Handled> | R`.
Each factory is two overloads whose order is load-bearing:
1. `Handlers<T, R>` — the exhaustive form, and the contextual type of the
handler-map popup;
2. `Handled extends Exact<Partial<Handlers<T, R>>, Handled>` intersected with
`MustBePartial<T, Handled>`, plus `Fallback<T, Handled, R>` — a partial
handler map plus the fallback, rejected when the map already covers `T`.
#### Why
- **The fallback is an argument, not a property.** TypeScript fixes a property's
contextual type before it infers its sibling keys, so `_: (s) => …` in the
handler map can only see all of `T`, never `Exclude<T, keyof Handled>`. A later
argument is contextually typed from inference on an earlier one, so the split
is what makes the remainder expressible.
- **The redundant-fallback guard is an F-bounded constraint.** A map that
already covers `T` plus a fallback is rejected by folding
`MustBePartial<T, Handled>` into `Handled`'s own constraint. The guard is
checked _after_ `Handled` is inferred, so the contextual pass that types the
handler callbacks survives. The obvious conditional
`Exclude<T, keyof Handled> extends never ? …` in the fallback's parameter
type is evaluated while `Handled` is still its constraint and rejects every
partial map whose callbacks are context-sensitive.
- **Overload order keeps both messages.** #1 supplies the contextual type
(`a, b, c`); #2 accepts a partial map once a fallback is present, so its popup
is optional (`a?, b?, c?`). A gap without a fallback is reported against #1.
- **`R` needs an inference site.** `R` inside the `Exact<…>` constraint is not
one, so `handlers: Handled & Partial<Handlers<T, R>>` re-adds it; without that
`R` collapses to `unknown` when the handler params are inferred.
- **`Exact` restores the excess-property check.** TypeScript skips it for a
generic constraint, so without `Exact` the handler map accepts keys outside
`T`.
- Two factories, not four: the fallback is an argument, not a separate API.
#### Rejected
- **Single-object `_`** (the former shape). `_` sees only all of `T`; the
remainder is not expressible there, and an exhaustive map plus `_` was
accepted.
- **Curried handlers-first** — `(handlers)(fallback)`. Rejected: two calls for
the common case. It is not needed for the redundant-fallback guard, which the
F-bounded constraint already provides (see Why).
- **`this` / HKT self-reference.** `this` is post-construction (method bodies,
return positions); a parameter's contextual type is pre-construction.
`keyof this` in an interface method is the interface, not the literal.
- **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`, 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
near-miss member, no `_` in the exhaustive popup, `NoInfer`/floor, `keyof P`
counts optional keys, not pipe-friendly) hold where they still apply.
#### Known issue
- `PatternReturns` must be
`ReturnType<Extract<ValueOf<P>, (...args: never[]) => unknown>>` so it survives
the closed, partly-optional `P` constraints.
- `Parameters<typeof factory>[0]` resolves only the **last** overload, so it is
not a sound "rejected" oracle for a factory. Factory-negative tests use
`@ts-expect-error` call sites (the test file only — the general ban stands).
## Shared internals
#### Decision (2026-09)
`src/matcher-shared.ts` holds the universe-agnostic pieces both matchers use:
`UnaryFn`, `PatternReturns`, `RedundantFallback`, `HandlerMap`, the shared
`Matchable` universe, the `PatternKey` key projection and its `Member` inverse,
and the `Stringified` / `Collisions` / `UnsupportedReason` / `UnsupportedUniverse`
/ `UniverseGate` universe gate.
#### Why
- `RedundantFallback`'s property name is the diagnostic, so one definition
keeps the two matchers' message from drifting; the other pieces appear
verbatim in both public signatures or are the same projection over each
matcher's universe.
- **`Matchable` is one definition, not two.** The primitive-union matcher's
universe and the tagged-union matcher's allowed `Tag` values are the same set,
so aliasing them keeps the two matchers from drifting apart on what they
accept (`symbol`/`bigint` rejected once).
#### Rejected
- **A generic `Matcher<Universe>` over the interface pair, `Handlers`,
`Fallback` and `MustBePartial`.** Each is built from its own universe
(`Tags`/`MapTaggedUnion` vs the primitive values); abstracting over the
F-bounded `Handled` constraint that makes the remainder work risks the
contextual typing it exists to preserve. `Matchable`, the `PatternKey` /
`Member` projection and the `UniverseGate` are the pieces both universes
genuinely share.
## Supported universes
#### Decision (2026-09)
A universe must be a **finite union of literals** with **no
value/stringification collision**. Broad types (`string`, `number`, a template
literal) and `"true" | true` / `1 | "1"` are rejected; the factory intersects
`UniverseGate<T>` (the `UnsupportedUniverse<Reason>` diagnostic) into the
handler and fallback parameters. `Member<T, K>` replaces the `PatternParam<K>`
inversion: the handler parameter is the member(s) of `T` whose `PatternKey` is
`K`, so a standalone `"true"` is `"true"`, not `true`.
#### Why
- **`PatternKey` is not injective.** `"true"` and `true` (and `1` / `"1"`)
share a runtime key, so `PatternParam<K>` cannot recover the member.
`Member<T, K>` inverts against `T`, which is exact.
- **Broad types cannot be proven exhaustive.** An index-like map lets a partial
object satisfy the exhaustive overload and reaches the `dispatch` throw.
Rejecting at the boundary avoids threading an open/closed branch through
`Handlers`, `Fallback` and `MustBePartial`.
- **The finite-literal predicate is `IsLiteral<PatternKey<T>> extends true`.**
`IsLiteral` is `boolean` for a union that mixes a literal with a broad type
(`"a" | \`x-${number}\``), so `extends false`would treat the mix as
supported;`extends true` is the check that rejects it.
- **Collisions are rejected, not merged.** `Member<T, K>` would be sound (the
handler gets the union), but the API is one handler per member; rejecting
keeps `Member` a singleton and the remainder exact.
- **The collision predicate is type-checkable.** `Collisions<T> =
Extract<T, Stringified<T>>` catches numeric collisions too.
- **The gate is an intersection, not a branch,** so `R` inference and the popup
survive; a conditional parameter type would not.
#### Rejected
- **Open universes with a required fallback** (`fix/open-universe-*`): sound,
but left the collision hole and added an `IsLiteral` /
`OpenUniverseNeedsFallback` branch through every handler type. Findings, kept
so they are not re-run: `{}` satisfies an index signature (and `Exact` misses
it); an index signature dominates contextual typing; `R` infers only from a
non-self-referential parameter type (`{ [K in keyof H]: … R … }` gives
`unknown`); an F-bounded guard referencing `keyof Handled` in `Handled`'s own
constraint sees the constraint, not the map; all handlers share one `R` (only
the fallback widens it); `IsLiteral` is the finite-literal predicate. The
user-facing consequence — open universes are a parsing or registry concern,
not a dispatch one — is guidance in README § Why open universes are rejected.
- **`Member<T, K>` without the gate:** sound, but a colliding handler gets a
union and `1 | "1"` stays one runtime key.
- **A round-trip injectivity gate** (`IsEqual<T, PatternParam<PatternKey<T>>>`):
over-rejects standalone `"true"` / `"false"` / `"null"` / `"undefined"`.
- **A case-list / ts-pattern builder:** removes the collision class but drops
the object map (footprint, popup) and reimplements an existing library.
- **Normalize numeric keys to strings:** makes `PatternKey` injective but
changes "numeric keys stay numbers" and defeats the numeric dispatch fast
path.
#### Known issue
- A multi-collision universe lists every collision in the diagnostic.
- The `dispatch` throw is unreachable through the typed API; the throw tests
widen the factory to `Function` to reach it.
## Tagged-union matcher
#### Decision (2026-09)
`getTaggedUnionMatcher` / `getTaggedUnionMatcherW` mirror the primitive-union pair
with one extra curried step for the discriminant key:
```ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
const area = getTaggedUnionMatcher<Shape>()("kind")({
circle: (s) => Math.PI * s.radius ** 2,
square: (s) => s.side ** 2,
});
const fallback = getTaggedUnionMatcher<Shape>()("kind")(
{ circle: (s) => … },
(s) => …, // s: { kind: "square"; side: number }
);
```
The key is a separate call because `K` is inferred from its literal argument and
`T` is fixed by the first factory; one call could not infer both.
`Discriminated<T>` restricts the key to properties whose values are tags.
#### Why
- **Same fallback/remainder machinery as the primitive-union matcher.** `HandledTags`
recovers the tag values the map handled (`Member<Tags, keyof Handled>`) and
`Narrowed<T, K, Exclude<Tags, …>>` is the fallback's parameter; the
redundant-fallback guard is the same F-bounded constraint. Only the "universe"
changes — `T`'s members instead of primitive values.
- **`T extends object`, not `Record<PropertyKey, unknown>`.** An `interface` has
no implicit index signature, so the `Record` constraint would reject
interface-based unions. The runtime reads the tag off `object` with one
assertion, the tagged twin of the primitive-union dispatch's `shape as string | number`.
- **`Narrowed` distributes over `T`, narrowing `K` to the tag.** A member whose
`K` cannot take the tag drops out; a duplicated tag yields a union of members
instead of dropping one. `T[K] extends V` returns the exact member (a
discriminated union's declared interface) untouched, so the mapped form only
handles a property that is itself a union.
- **A union-valued or optional discriminant is supported.** A single shape whose
property is a union (`{ color: "red" | "green" | "blue" }`) is narrowed per
handler instead of being passed `never`. Matching a defined tag on an optional
property (`{ type?: "x" }`) proves the key is present, so it becomes required
(`{ type: "x" }`); the `undefined` tag narrows it to `{ type?: never }` under
`exactOptionalPropertyTypes` (absence) or `{ type?: undefined }` when the
property explicitly admits `undefined`.
- **A `boolean` / `null` / `undefined` tag goes through the shared
`PatternKey` / `Member` projection.** `Discriminated` admits those tags
(they are in `Tag`), but they cannot key a mapped type, so the handler map is
keyed by the stringified form (`true` → `"true"`) and `Member` inverts it
against the tag set to recover the member. This is the same projection the
primitive-union matcher uses over its universe, which is why it lives in
`matcher-shared.ts`.
#### Known issue
- A tag need not be unique across the union. Two members sharing one is not a
soundness hole: they select a single runtime key, so one handler receiving
their union is the only correct behavior — the key is simply not a
discriminant. The gate rejects only the distinct-value collision
(`true | "true"`), where two values share a key and `Member` can no longer
invert it; see § Supported universes. Pinned by the duplicate-tag tests in
`src/tagged-union.test.ts`.
## Primitive universe
#### Decision (2026-09)
The universe (`Matchable`) is `string | number | boolean | null | undefined`,
with `boolean` admitted as `true | false`.
`boolean`/`null`/`undefined` are not property keys, so handler-map keys are a
projection (`PatternKey`: each member stringified) and `Member` inverts it
against the universe, so callbacks receive the real member (`true`, not
`"true"`; the standalone string `"true"` stays `"true"`). The popup offers
`true`, `false`, `null`, `undefined` by name (verified over LSP). The same
projection is shared with the tagged-union matcher; see § Tagged-union matcher.
The supported universes are constrained as described in § Supported universes.
#### Why
- Runtime dispatch indexes with the raw `shape`; `handlers[true]` coerces to
`"true"` at runtime exactly as `String` would. The `shape as string | number`
assertion only placates `TS2538` and buys the number fast path (an explicit
`String()` defeats V8's numeric-key path: measured ~2× on number-keyed
dispatch).
- `symbol`/`bigint`/`NaN`/`-0` are rejected, and a member colliding with its
stringification is rejected by the gate: user-facing, stated once in
[README § Caveats](../README.md#caveats).
+131
View File
@@ -0,0 +1,131 @@
# Publishing
Publishing is CI-only: local `npm publish` is not supported, and the maintainer
triggers releases from `main`. The mechanics are in
[scripts/release.sh](../scripts/release.sh) and
[scripts/release-notes.sh](../scripts/release-notes.sh); the job graph is
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml).
## Release steps
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release`. VS Code opens
[CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because
pubv refuses a dirty tree, the edit is committed first (then folded into the
release commit), and pubv suggests a version from those notes to confirm or
edit.
3. `scripts/release.sh` creates one release commit (graduated changelog +
`package.json` bump, amended together), tags it, and pushes.
4. CI fires on both pushes. `publish` runs on the tag (build + publish checks +
release page + `npm publish`), while `release-gate` recognizes the release
commit and skips `build`/`maintain`: the tag verifies the identical SHA, so no
work is duplicated. The publish checks pass before the artifact is published,
and the release page is created from the matching Keep-a-Changelog section
_before_ `npm publish`, so a broken page fails CI without consuming a version
and `npm publish` stays the last step.
## CI-only publishing
#### Decision (2026-09)
Releases are cut by CI from `main`; there is no local `npm publish` and no
`publish:*` script in the `check` chain.
#### Why
- The tag is the artifact marker: CI verifies the exact commit it points at, so
a local publish could ship something the tag does not describe.
- `publish:publint` / `publish:attw` validate the _publishable artifact_, which
needs a fresh build; they are not source-correctness checks and do not belong
in `check`.
## The release commit is assembled from two tools
#### Decision (2026-09)
`create:release` uses `pubv` for the changelog graduation and bump heuristic,
then `npm version` for the `package.json` + lockfile bump, amended into a single
release commit.
#### Why
- We want hand-written Keep-a-Changelog notes, an `[Unreleased]` ->
`## [x.y.z] - DATE` graduation, and a tag on the exact commit that gets
published — and no single tool did both the graduation and the `package.json`
bump.
- Split by strength: `pubv` (tiny, changelog-driven) owns preflight, the
interactive major/minor/patch heuristic, and graduating and committing
`CHANGELOG.md` (no tag, no push); `npm version` syncs `package.json` + the
lockfile; `--amend` folds them into one commit; the tag is created _after_ the
amend so it is never orphaned.
- The notes are finalized _before_ pubv because its bump heuristic reads the
`[Unreleased]` body — editing afterwards would inform the changelog only, not
the version. The staging commit that satisfies pubv's clean-tree check is
folded back into the single release commit.
#### Rejected
- The conventional-commits family: the history is gitmoji, not Conventional, and
the notes are hand-written (see
[workflow.md § Commit messages](./workflow.md#commit-messages)).
- `changesets` / `rtk`: config plus a heavier flow that fights the CI-only
publish.
- `knope` / `kacl` / `bestikk`: changelog-only (no `package.json` bump) and
5-year / 2-year / brand-new maintenance.
- `pubv` alone: it never writes `package.json`.
- `versions` (silverwind): good Gitea support, but pairing it with a hand-rolled
promote became a ~180-line script, which this ~30-line version replaces.
## The version has one source of truth
#### Decision (2026-09)
The version is derived from the graduated `## [x.y.z]` heading in
[CHANGELOG.md](../CHANGELOG.md) and written to `package.json` +
`package-lock.json` by `npm version`.
#### Why
- `release.sh` reads the version from the changelog, so the changelog is the
input and `package.json` the derived copy — one direction, no drift.
#### Rejected
- A `## Version` line in the README: it makes `release.sh` responsible for a
third file.
- Linking `package.json` from the README: it invites a hand-maintained duplicate
the link does not keep in sync.
## Release notes are extracted from the changelog
#### Decision (2026-09)
`scripts/release-notes.sh <tag>` prints the Keep-a-Changelog section for the tag
and exits non-zero when it is missing.
#### Why
- CI reuses the release body from the same file that drove the version, so the
page and the changelog cannot disagree.
- Failing on a missing section means a release can never publish an empty body.
A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`.
## Token gates
#### Decision (2026-09)
The Gitea release page uses the run's automatic token (`github.token`).
`npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env`. A final
`always()` step fails the job unless both halves reported `success`.
#### Why
- The automatic token needs only `contents: write`, so the release page needs no
secret gate.
- `secrets` is not allowed in a step `if`, so `NPM_TOKEN` must be lifted into
job-level `env`; an unset secret then skips the publish instead of attempting
an unauthenticated one.
- A tag is all-or-nothing: without the `always()` guard a skipped or failed npm
half would leave the job silently green. The guard turns it red.
Set `NPM_TOKEN` (npm publish rights) under Settings -> Actions -> Secrets.
+214
View File
@@ -0,0 +1,214 @@
# Testing
For this library the types _are_ the feature, so a runtime-only test loop would
verify the wrong thing. The commands are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped
the way it is.
## Type-driven development
The rules — the loop and the pairing rule — are in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
What follows is why and what was rejected.
#### Decision (2026-09)
The compile-time expectation is written before the runtime assertion, and both
before the implementation.
#### Why
- A runtime-only test can pass while the type is wrong, so a type-level library
would ship a broken feature its tests bless.
- The type error is a more precise spec than a failing assertion, because it
states the exact expected type before the logic exists.
#### Rejected
- Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product.
- Testing the type only: it would not catch handler dispatch or the `_`
fallback (see `src/primitive-union.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)). CI gates that
coverage at 100% (see [ci.md § Coverage threshold](./ci.md#coverage-threshold)).
## Handler arguments
#### Decision (2026-09)
A handler's `expectTypeOf(shape)` is always paired with an assertion on the
argument `dispatch` actually passed: `assert.equal` where the handler runs for
one shape, `assert.ok(s === … || s === …)` over the set a `_` fallback accepts
(`assert` is imported as `strict`, so each comparison is `Object.is`). Where a
test should also prove that the _exact_ value reached the handler unchanged,
the fallback returns the shape verbatim and the call site asserts it.
#### Why
- The parameter's type is what the compiler inferred from the pattern; the
argument is what the runtime passed. Only the second can drift, and the keys
whose property name differs from their value (`true`, `null`, `1`) are
exactly where it can — see [library.md](./library.md).
- `String(shape)` at the call site keeps every type expectation and every
return-value assertion green; the argument assertions fail (10 of the 33
tests). Without them the suite never looks at the passed argument.
- Returning the shape verbatim costs a widening pattern nothing: the remainder
type joins the union of handler returns in place of a marker literal, so the
test still shows the widening it is named for.
#### Rejected
- A recorded `unknown[]` of every fallback call compared with `deepEqual`:
strong, but it couples the assertion to call order, and the sink sits three
blocks away from the value it observes.
- `typeof` checks: they cannot separate `2` from its key text `"2"`, which is
the drift a fallback with a numeric remainder can hit.
- One expected value asserted inline in a fallback: its argument is a _set_ of
shapes, so only the disjunction holds on every call.
## AAA ordering
The rule is in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
#### Decision (2026-09)
Test bodies read arrange → act → assert: inputs (the factory) set up first, the
subject exercised once from them, all checks last — types then runtime. The
blocks are labeled with `// Arrange` / `// Act` / `// Assert` comments and
separated by a blank line; an empty block drops its label.
#### Why
- Interleaved setup/checks hide what runs vs. what is observed; the eye
re-reads the block to find the seams.
- A factory built mid-test invites a second throwaway call of the subject;
arranging it once makes the positive construction and the negative
`Parameters<…>` check share one source of truth.
- Labels make the seams explicit, not inferred — grep-able and reviewable
without reading the statements.
#### Rejected
- Unlabeled ordering (bare blank lines): the seams still have to be found by
reading; the labels cost nothing.
## Test helpers
The rule is enforced by `import/no-relative-parent-imports`; this section records
why the mechanism is shaped like this.
#### Decision (2026-09)
A shared test helper — code that scattered `*.test.ts` files import to do their
testing — lives under `src/util/__tests__/` and is addressed by the
`#test-utils/…` self-reference (`package.json#imports`:
`"#test-utils/*": "./src/util/__tests__/*"`), never by a relative path:
```ts
import { LspSession } from "#test-utils/lsp-completion.ts";
```
#### Why
- The lint rule bans upward (`../`) imports, and a cross-cutting helper can
always be placed above _some_ scattered consumer, wherever it goes. Name
beats path: a `#test-utils/…` specifier has no direction, so the rule never
fires and file moves only touch the one mapping in `package.json`.
- `#…` is Node's reserved prefix for _private_ subpath imports: publishing
`package.json` leaks nothing and resolves nothing for consumers.
- `__tests__` as the folder name is not about tests living there; it is the
directory pattern `tsconfig.build.json` already excludes, so a helper can
never be emitted into `dist/` and shipped by accident.
- Node's own resolver handles `#…` under `--strip-types`, and `tsc` resolves it
via the same `imports` field — one mechanism for runtime and type gate, no
loader needed.
- The helper uses `node:` builtins (it drives a language server), so
`import/no-nodejs-modules` is off for `src/util/__tests__/**` in
`.oxlintrc.json` — the same exception the `scripts/**` scope carries.
- Helpers carry no library coupling: their tests probe in-memory documents
whose contextual types are written inline, so only the helper's own contract
(marker handling, position, label extraction) is under test. A test that
asserts a _matcher's_ popup belongs with the matcher.
#### Rejected
- A helper beside the tests (`src/util/test.ts`, flat `src/`): composes only
while `src/` stays flat; the first nested test reaching it reintroduces the
banned upward import.
- Bare `~/…` specifier: not valid in `imports` (keys must start with `#`) —
resolution fails at runtime with `ERR_MODULE_NOT_FOUND`. A `#~/…` “home”
shorthand was dropped in review; `#test-utils/…` states what it is.
- tsconfig `paths` alias: resolves for `tsc` but not for plain
`node --test --strip-types` (no loader hook), breaking the fast tier.
- Turning `import/no-relative-parent-imports` off for test files: the rule
still guards non-test helpers importing each other, and the exemption is
only needed for the one specifier the mapping already solves cleanly.
## Autocomplete
#### Decision (2026-09)
Completion is verified by driving the repo's own language server
(`tsc --lsp --stdio`, the same server pi's LSP extension talks to) through
the test helper `#test-utils/lsp-completion.ts`
(`src/util/__tests__/lsp-completion.ts`), not through the type system:
```sh
node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
```
The script prints the labels the server offers at a `/*COMPLETE*/` marker inside
`<file>` (the marker is stripped before the document is sent). Its `LspSession`
is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the
helper itself against inline documents, never the library's code — and by
`src/primitive-union.test.ts`, where the same probe asserts the matcher's popup;
the CLI is for manual inspection.
#### Why
- Completion is a contextual-type property: it depends on which overload
signature TypeScript picks for the object literal, and no type-level assertion
observes that.
- `Parameters<typeof factory>[0]` resolves only the _last_ overload, so it is
not the popup's contextual type either — see
[library.md § Matcher shape](./library.md#matcher-shape).
- The server is the only ground truth; the script reproduces what the editor
shows.
#### Rejected
- **`expect-type` would not work**: there is no operator for “the popup offers
these labels”. `toExtend` / `toEqualTypeOf` test assignability and cannot say
which overload supplied the contextual type.
- **Checking by hand in the editor**: not reproducible in review or by an agent.
- **`@ts-expect-error` at a completion position**: it asserts the absence of a
compile error, not the presence of specific labels.
#### Known issue
- Each test spawns its own `tsc` server so the tests share no state and pass in
any order; the file is an integration test (~1.6 s) that needs `node_modules`.
`didOpen` is handled in order before the completion request, so no settle
delay is needed.
- The server answers some requests with a string id (`client/registerCapability`);
the client must tolerate `string | number` ids or the server stalls.
- `LspSession.close()` sends `shutdown` and then closes stdin instead of
sending `exit`. The TS 7 Go server's `handleExit` returns `io.EOF`, cancelling
the background context while a watch update is still in flight, and logs a
bare `context canceled` before exiting 1; EOF on stdin exits 0 with no output.
The kill stays as a fallback for a server that does not exit.
## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise.
It is a known false positive, so `typescript/no-floating-promises` is off for
`**/*.test.ts` in the `.oxlintrc.json` override rather than repeated as a
file-level header (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+346
View File
@@ -0,0 +1,346 @@
# Tooling
Every tool below was chosen and configured deliberately. The commands a
contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md) and the versions
in [package.json](../package.json).
## Tool inventory
- **TypeScript 7** — type checker and build (`tsc`).
- **node --test** + `--strip-types` — test runner.
- **c8** — coverage for `test:ci`.
- **oxlint** — Rust linter, type-aware via **oxlint-tsgolint** (typescript-go).
- **oxfmt** — Rust formatter (Prettier-compatible) for JS/TS, JSON/JSONC, YAML,
Markdown, MDX, and more; its `package.json` key sorting replaces
`sort-package-json`.
- **cspell** — spell checking.
- **knip** — unused dependencies, exports, and files.
- **check-outdated** — dependencies behind the registry; exits non-zero when any
is outdated.
- **publint** — validates `package.json` for ESM publishing.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` against module-resolution
scenarios.
- **lefthook** — git hooks.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents
(project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via
`tsc --lsp --stdio`.
- **vscode-languageserver-protocol** — LSP client and protocol types for the
autocomplete test helper (`src/util/__tests__/lsp-completion.ts`).
When each runs is in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
## TypeScript and build
### One type-check config, one emit config
#### Decision (2026-09)
`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`.
`tsconfig.build.json` adds the emit-only options (`declaration`, `sourceMap`,
`inlineSources`, `outDir`, `target: es2024`,
`rewriteRelativeImportExtensions: true`) and excludes test files.
#### Why
- The editor and CI type-check from one config while the build emits from the
other, so a test file cannot leak into `dist/`.
- `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so
debuggers map into `src/` without it being shipped.
- `declarationMap` stays off: a `.d.ts.map` cannot embed source and would
dangle.
### The build starts from an empty `dist/`
#### Decision (2026-09)
`npm run build` runs a `prebuild` hook that empties `dist/`.
#### Why
- `tsc` does not prune orphaned emit output — dropping `declarationMap` left
stale `*.d.ts.map` files — so reproducibility needs an empty `dist/`.
- `prebuild` removes only `dist`; the manual `clean` resets `dist` + `coverage`,
so a local coverage report survives a build.
### Source imports use `.ts` extensions
#### Decision (2026-09)
Source imports use `.ts`, never `.js`.
#### Why
- `node --strip-types` resolves the `.ts` form at test time.
- `rewriteRelativeImportExtensions` rewrites them to `.js` in the emitted
JavaScript.
- The emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves
(see [README § Requirements](../README.md#requirements)), so no
post-processing step is needed.
#### Rejected
- "Pre-fixing" an import to `.js`: it breaks the inner `node --strip-types`
loop.
## Linting and formatting
### Type-aware oxlint is a config property
#### Decision (2026-09)
Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
(powered by `oxlint-tsgolint`).
#### Why
- The script commands stay clean — no CLI flag.
- A config property cannot be forgotten on one call site.
#### Rejected
- A CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the mode in
two places and invites them to drift.
### `oxlint-disable` directives live next to the code
#### Decision (2026-09)
A type-aware rule that false-positives **at one site** is silenced with a
source-level `oxlint-disable` directive (see `src/primitive-union.ts`). A rule that is
wrong for a whole **file class** is turned off in a `.oxlintrc.json` `overrides`
entry instead — e.g. `typescript/no-floating-promises` (synchronous
`expectTypeOf` reads as an unhandled promise) and `unicorn/no-null` (intentional
`null` inputs) for `**/*.test.ts`. The same exemption is not repeated as a
file-level header in every affected file.
#### Why
- A one-site disable sits next to the code it silences, visible to anyone
reading the source, and the rule stays on everywhere else.
- A file-class rule is a property of the file class, not of one line; the
override states it once, where the rest of the file-class config lives.
#### Rejected
- A project-wide disable in `.oxlintrc.json` for a one-site false positive: it
hides the exemption from the reader of the affected code and switches the rule
off repo-wide for a one-site problem.
- A repeated file-level `oxlint-disable` header for a file-class false positive:
the copies drift and scatter one config decision across the tree.
#### Known issue
- Both placements are _human_ last resorts. AI agents must neither add a source
disable nor edit `.oxlintrc.json`; they fix the type at its root (see
[AGENTS.md § Never do](../AGENTS.md#never-do)).
### Unwanted stylistic rules are turned off in the config
#### Decision (2026-09)
A stylistic rule the project rejects is `"off"` in the `.oxlintrc.json` `rules`
map, not silenced at a use site. Current entries: `eslint/capitalized-comments`
(comments may start lowercase) and `eslint/no-ternary` (ternaries are allowed),
joining the oxfmt-superseded rules already off.
#### Why
- The rule is wrong for the whole project, not mis-firing at one site, so there
is no line to annotate.
- Keeping the two mechanisms separate keeps a source-level `oxlint-disable`
meaningful: it marks a lone exception.
#### Rejected
- A source-level `oxlint-disable` per use: the same exemption repeated at every
site, and oxfmt can move the site.
### `check:tsc` runs first
#### Decision (2026-09)
`check:tsc` runs first in the `npm run check` chain.
#### Why
- A type error short-circuits the rest, which is faster than running
oxlint/oxfmt and failing on `tsc` at the end.
### `.editorconfig` is a fallback, not a gate
#### Decision (2026-09)
`.editorconfig` exists for editor compatibility; where both apply,
`.oxfmtrc.json` is authoritative.
#### Why
- `.editorconfig` covers the files oxfmt does not format: shell scripts,
dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG`
buffer.
- oxfmt is the formatter; the overlapping keys only keep non-oxfmt editors close
to the formatted result, so they cannot disagree with the checker.
## Static analysis and packaging
### `knip` omits the `types` category
#### Decision (2026-09)
`knip --include dependencies,exports,files` omits the `types` category.
#### Why
- `types` produces systematic false positives for libraries whose exported types
are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate.
### `knip` does not list the library entry
#### Decision (2026-09)
`knip.json` declares `"entry": ["scripts/*.ts"]`; the library entry
`src/index.ts` is not listed.
#### Why
- knip derives the public entry from `package.json` `exports` and resolves it to
`src/index.ts` itself, so naming the file is a redundant pattern and
`maintain:knip` reports it as a configuration hint.
- Listing `scripts/*.ts` does not switch that derivation off: the scope stays
clean with or without `dist/`, and with the barrel's own test removed.
#### Rejected
- `"src/index.ts"` in `entry`: it silenced an `unused files` report for the
barrel, which the derivation above no longer produces; keeping it only adds a
hint.
- `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a
relation knip already resolves.
### `maintain:outdated` ignores `@types/node`
#### Decision (2026-09)
Pass `--ignore-packages @types/node`.
#### Why
- DT pins `@types/node`'s `latest` dist-tag to LTS (22.x); current-line types
ride other tags. Scan sees latest < installed — permanent "reverted", exit
1, zero signal. `--ignore-pre-releases` no help: 22.20.3 is stable.
#### Rejected
- `--types major,minor,patch`: hides real reverted reports elsewhere.
- `@types/node@26.*`: tag stays wrong across majors; un-pin per bump = ritual.
#### Known issue
- A genuinely behind `@types/node` goes unreported; match it to
`.node-version` by hand.
### `attw` targets ESM-only
#### Decision (2026-09)
`attw --profile esm-only` is used.
#### Why
- The package is intentionally ESM-only (no CommonJS shim), so CJS resolution
scenarios are out of scope by design, not a bug.
### `tslib` is deliberately not used
#### Decision (2026-09)
`tslib` is not a dependency.
#### Why
- `tslib` is a runtime helper for old ES3/ES5 targets; this project targets
ES2024.
## Git hooks and script wiring
### `LEFTHOOK_FILES` scopes commands to staged files
#### Decision (2026-09)
The pre-commit hook sets `LEFTHOOK_FILES` to the staged-files list, and the
affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole
project.
#### Why
- It keeps `package.json#scripts` the single source of truth; `lefthook.yml`
only says what to run on which files.
- The same script works by hand (whole project) and staged (scoped), so there is
no second command to maintain.
## Language server tooling
### `vscode-languageserver-protocol` backs the autocomplete helper
#### Decision (2026-09)
The autocomplete helper (`src/util/__tests__/lsp-completion.ts`) drives
`tsc --lsp --stdio` through `vscode-languageserver-protocol`'s
`createMessageConnection` and its typed request / notification objects, instead
of a hand-rolled JSON-RPC client.
#### Why
- Framing, `Content-Length` parsing, the pending-request map and server-request
dispatch are protocol plumbing the helper only reimplemented; the official
client owns them and tolerates the server's `string | number` ids.
- `InitializeRequest`, `CompletionRequest`, `DidOpenTextDocumentNotification`,
… carry their parameter and result types, so `CompletionList` / `CompletionItem`
replace the helper's ad-hoc shape guards.
- The `./node` entry re-exports `vscode-jsonrpc/node`, so one devDependency
supplies both the transport and the protocol types. It is test-only and never
ships (`files` publishes `dist/` only).
#### Rejected
- `vscode-languageclient`: the editor-side client with a full feature registry
— far more than a test helper needs.
- Generic JSON-RPC (`jsonrpc-lite`, `jayson`): still no LSP types, so they
replace framing only and leave the typed protocol surface unimplemented.
- Keeping the hand-rolled client: the low-level shape is the maintenance cost
the helper exists to remove, and it must be re-audited against the server.
## Editor and agent tooling
### VSCode integration
- Recommended extensions are in
[.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript
native-preview, EditorConfig, todo-tasks).
- TypeScript 7 runs via the `typescriptteam.native-preview` extension.
- The oxc extension provides oxlint squiggles and oxfmt format-on-save;
`.vscode/settings.json` pins it per language so a local `[language]` formatter
setting cannot override the project's choice.
### `@spences10/pi-lsp` is pinned and read-only
#### Decision (2026-09)
`@spences10/pi-lsp` is pinned to `0.0.47` and used read-only.
#### Why
- It inspects `node_modules/typescript`, sees major >= 7 with no
`lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the
repo's own `tsc --lsp --stdio` — no `typescript-language-server` dependency is
needed.
- Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server
--stdio` and are TS6-only.
- It is _intermediate_ agent feedback (hover, references, definition, symbols,
diagnostics), with no rename / code-action / apply-edit surface, and is never a
gate — `npm run check` / `verify` are.
- `.pi/settings.json` is the committed declaration; `.pi/npm/` is a gitignored
install cache that pi recreates on a trusted startup (running `npm install`
for any missing project package), so it is deliberately not tracked.
+174
View File
@@ -0,0 +1,174 @@
# Workflow
How work moves through the repository. The rules are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they are shaped the
way they are.
## Branching model
The model is GitHub Flow (single-developer); the steps are in
[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Context
behind it: Gitea has no collaborative review UI in use, so it is the lab, and
the project moves to GitHub once it is tested and ready.
#### Decision (2026-09)
Work is opened and closed by `create:branch` / `create:finish`, not by prose
plus hand-written `git`.
#### Why
- The preconditions were prose, and prose rots: a rule nobody checks is a
suggestion. A script asserts, then acts, so the branch or merge only exists if
the assertions passed.
- Type-driven work is only trustworthy if the baseline was green before the
first edit. Cheap checks run first and `npm run test` last, so the expensive
gate is not paid on an ineligible tree.
- The merge half owns the post-merge `npm run verify`, so a merge cannot land
unverified. The push stays with `create:release` so the merge is reviewed
locally first; `main` is therefore routinely ahead of its upstream between a
merge and the release that ships it. `create:branch` requires only that `main`
is not _behind_ — matching `create:finish`, which tolerates the local merge and
fast-forwards over a remote one — rather than an exact match.
- Every failure is non-mutating except the baseline test, which runs on `main`
after switching there: a red `main` restores the branch you started on, and a
merge conflict aborts back to the feature branch rather than stranding a
half-merged `main`.
#### Rejected
- Hand-written `git switch -c` / `git merge`: same rules, no enforcement.
- Reusing `pubv`'s preflight for `create:branch`: release-shaped, third-party,
and it would pay for a build and pack a new branch has no use for.
- Leaving the merge to reviewer judgment: that judgment moved earlier, to the
handover review before `create:finish`, rather than living in a command anyone
can run from a dirty tree.
- Fast-forward instead of `--no-ff`: `--no-ff` keeps each unit of work visible
in `git log`.
- Pushing from `create:finish` to keep `main` level with its upstream: it would
trade the local review the push waits for for a network side effect, and a
failed push would leave the merge landed but unpublished.
## Changelog notes
The rule is in
[CONTRIBUTING.md § Rules the tools don't enforce](../CONTRIBUTING.md#rules-the-tools-dont-enforce).
#### Decision (2026-09)
A merged branch carries its own summary under `[Unreleased]` in
[CHANGELOG.md](../CHANGELOG.md), added before `create:finish`;
`create:release` graduates it into the tagged section (see
[publishing.md](./publishing.md)).
#### Why
- `create:release` derives the bump heuristic from the `[Unreleased]` body, so
the notes must exist before release day.
- The contributor has fresh context; at release day the intent of a branch is
only its diff.
- Gitmoji subjects are signposts, not semantic keys, so notes cannot be derived
from the history.
#### Rejected
- Generating notes from subjects at release time: subjects carry no parseable
type/scope (see § Commit messages).
- The maintainer writing one summary during `create:release`: reconstruction
after the fact.
- Enforcing it in `create:finish`: the front doors assert git state, not
content — and _notable_ is exactly the judgment a tool cannot make.
## Script prefix convention
The prefix taxonomy is the rule, and it lives in
[CONTRIBUTING.md § Script prefix convention](../CONTRIBUTING.md#script-prefix-convention).
The design behind it: bare scripts are the tier entry points — a single tool
(`build`, `clean`) or an aggregator of a `prefix:*` family (`check`, `fix`,
`test`, `watch`, `maintain`, `setup`) — while `verify` composes `check` +
`test:unit` into the whole-project gate (it uses `test:unit`, not `test`,
because `check` already runs `check:tsc`).
#### Decision (2026-09)
`create:` is the prefix for workflow front doors, with no bare `create`
aggregator.
#### Why
- Every member creates something real: a branch, a release, the compiled
doc-tests.
- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its
first members, so it could not go invisible the way the retired `use:` prefix
did.
- `publish:*` already set the precedent for a prefix without an aggregator.
#### Rejected
- `run:` / `perform:`: they mean only "do the named thing", so every script fits
and the taxonomy collapses.
- `git:`: names the tool, not the lifecycle moment, and implies passthrough
aliases.
- `start:`: describes the branch half, not the release.
- `cut:`: idiomatic but needs VCS slang to decode.
- `flow:`: overloaded in a type-level matching library.
- The existing families: `check:*` is read-only (CI would run a state-mutating
command), `fix:*` reviews as a diff not a branch, `maintain:*` is advisory and
never a gate.
## Feedback tiers
The table and invocation rules are in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this
section explains the split.
#### Decision (2026-09)
Fast, offline, staged-file checks sit in pre-commit; whole-project test runs in
pre-push and `verify`; slow or network-bound scans under `maintain`.
#### Why
- `watch:*` runs until killed, in its own pane, so it is the earliest tier,
firing on save before staging or commit.
- `check:tsc` / `check:oxlint` / `check:oxfmt` / `check:cspell` are fast
(~0.2–0.5s each), offline, and scope to staged files via `LEFTHOOK_FILES`, so
pre-commit gives instant feedback on what you typed.
- `test` (and its `tsc`) runs the whole suite over the whole project, and the
staged-file convention does not apply to the test runner, so it belongs in
pre-push, after the commits exist but before the push leaves the machine.
#### Rejected
- `maintain:*` in `check` or pre-commit: advisory, whole-project and
network-bound scans are not correctness gates and would slow the fast tier.
- Treating a green pre-commit as the definition of done: it sees only staged
files, hence `npm run verify`.
- A separate `git push` hook for `verify`: the pre-push test tier already covers
it.
## Commit messages
The convention is in
[CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages).
Examples: `:sparkles: Add watch tier with watch:test child`,
`:recycle: Move type-aware config to .oxlintrc.json; use source-level disable
directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The
body explains what and why, not how; link issues with `Resolves #...`.
#### Decision (2026-09)
Gitmoji subjects, imperative mood, wrapped 50/72, not Conventional Commits.
#### Why
- The history is gitmoji and predates any commit-lint tooling; switching would
rewrite the convention for no gain.
- The body carries the reasoning a reviewer needs; the subject is a signpost,
not a semantic key.
#### Rejected
- Conventional Commits: the release flow uses hand-written Keep-a-Changelog
notes, not generated ones, so the prefix has no automation value here (see
[publishing.md](./publishing.md)).
+51
View File
@@ -0,0 +1,51 @@
# CI job image for the Gitea act_runner: the runner's default job image with
# Node pre-planted where actions/setup-node looks first.
#
# Why this layout: setup-node ignores `node` on PATH; its only fast path is a
# probe of /opt/hostedtoolcache/node/<version>/<arch>. Without an entry there
# it downloads the ~50 MB distribution on EVERY job (the runner's job
# containers are ephemeral, so its tool cache never survives a job). The
# official node images keep exactly the layout setup-node expects under
# /usr/local, so this layer is a pure file overlay — no scripts, no env.
#
# Why not a host bind of /opt/hostedtoolcache: binds never self-prune. Docker
# images are content-addressed: the base layers dedupe against the act image
# the host already has, and `docker image prune` / re-pulls are the cleanup
# story.
#
# NODE_VERSION must match `.node-version` exactly. setup-node resolves a float
# like `26` to the latest known patch at runtime, so a bump silently busts the
# baked entry; `.node-version` is pinned to x.y.z and scripts/runner-image.sh
# guards the coupling. Rebuild + repoint `container.image` in
# .gitea/workflows/ci.yml on every bump.
#
# The extra `nodebase` stage is load-bearing: `COPY --from=` resolves its value
# as a *stage name* at parse time, before build args exist, so
# `COPY --from=node:${NODE_VERSION}` collapses to the invalid `node:` on
# frontends that do not expand args there. ARGs declared before the first FROM
# *are* expanded in FROM, so routing through a named stage works everywhere.
# Global scope: only visible to FROM lines, but that is exactly where we need it.
ARG NODE_VERSION=26.8.2
FROM node:${NODE_VERSION} AS nodebase
FROM catthehacker/ubuntu:act-latest
# ARGs do not cross stage boundaries; redeclare (with the same default, so a
# bare `docker build -f docker/Dockerfile .` still works) for the paths below.
# Keep this default in sync with the global one above.
ARG NODE_VERSION=26.8.2
# node image: bin/ + lib/ under /usr/local → tool cache: bin/ + lib/ under <ver>/x64.
COPY --from=nodebase /usr/local /opt/hostedtoolcache/node/${NODE_VERSION}/x64
# actions/tool-cache only accepts a cached tool when the sibling marker file
# "<version>/<arch>.complete" exists — tc.find() checks it and falls back to
# downloading otherwise, however complete the directory is. The marker is what
# tc.cacheDir() writes after *it* installs a tool, so a pre-baked entry has to
# reproduce it explicitly.
RUN touch "/opt/hostedtoolcache/node/${NODE_VERSION}/x64.complete"
# Fail the build (not CI) if the overlay or the version arg were wrong.
# Shell form on purpose: exec form (`RUN [...]`) does not expand ARG values.
RUN "/opt/hostedtoolcache/node/${NODE_VERSION}/x64/bin/node" --version
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": ["scripts/*.ts", "compat/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"]
}
+18 -52
View File
@@ -1,55 +1,21 @@
# EXAMPLE USAGE:
#
# Refer for explanation to following link:
# https://lefthook.dev/configuration/
#
# pre-push:
# jobs:
# - name: packages audit
# tags:
# - frontend
# - security
# run: yarn audit
#
# - name: gems audit
# tags:
# - backend
# - security
# run: bundle audit
#
# pre-commit:
# parallel: true
# jobs:
# - run: yarn eslint {staged_files}
# glob: "*.{js,ts,jsx,tsx}"
#
# - name: rubocop
# glob: "*.rb"
# exclude:
# - config/application.rb
# - config/routes.rb
# run: bundle exec rubocop --force-exclusion {all_files}
#
# - name: govet
# files: git ls-files -m
# glob: "*.go"
# run: go vet {files}
#
# - script: "hello.js"
# runner: node
#
# - script: "hello.go"
# runner: go run
min_version: 2.0.0
pre-commit:
parallel: true
commands:
oxlint:
glob: "*.{ts,tsx,js,jsx,mjs,cjs}"
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxlint' sh {staged_files}
oxfmt:
glob: "*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,yaml,yml,md,mdx}"
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxfmt' sh {staged_files}
cspell:
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:cspell' sh {staged_files}
typecheck:
run: npm run check:tsc
spell:
run: npm run check:cspell
sort:
run: npm run check:package
lint:
run: npm run check:eslint
format:
run: npm run check:prettier
outdated:
run: npm run check:outdated
pre-push:
parallel: false
commands:
test:
run: npm test
+5747
View File
File diff suppressed because it is too large. Load diff
+97 -7
View File
@@ -1,15 +1,105 @@
{
"name": "tiny-pattern-ts",
"private": true,
"version": "0.0.0",
"version": "0.9.0",
"description": "Exhaustive, type-safe pattern matching for TypeScript",
"keywords": [
"adt",
"algebraic-data-types",
"match",
"pattern",
"pattern-matching",
"typescript"
],
"homepage": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts#readme",
"bugs": {
"url": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://gitea.e1nsnull.de/tmu/tiny-pattern-ts.git"
},
"files": [
"dist",
"CHANGELOG.md",
"README.md",
"LICENSE"
],
"type": "module",
"sideEffects": false,
"imports": {
"#test-utils/*": "./src/util/__tests__/*",
"#test-tiny-pattern-ts": "./src/index.ts"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"build": "tsc -p tsconfig.build.json",
"prebuild": "rm -rf dist",
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell",
"check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}",
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc",
"clean": "rm -rf dist coverage src/doc-test/__generated__/*.test.ts",
"create:doc-tests": "node --strip-types scripts/create-doc-tests.ts && sh -c 'oxfmt src/doc-test/__generated__/*.test.ts' && tsc -p src/doc-test/tsconfig.json",
"fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts",
"create:branch": "./scripts/branch.sh",
"create:finish": "./scripts/finish.sh",
"create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files",
"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\"",
"verify": "npm run check && npm run test:unit",
"watch": "npm run watch:test",
"watch:test": "node --test --watch --strip-types \"src/**/*.test.ts\"",
"publish:attw": "attw . --pack --profile esm-only",
"publish:publint": "publint",
"setup": "npm run setup:git-commit-message",
"setup:git-commit-message": "git config commit.template commit-message-template"
},
"dependencies": {
"type-fest": "^5.9.0"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.3.1"
"@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1",
"@tsconfig/node26": "^26.0.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.6.1",
"c8": "^12.0.0",
"check-outdated": "^3.0.0",
"cspell": "^10.3.2",
"expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12",
"mdast-util-from-markdown": "^2.0.3",
"oxfmt": "^0.71.0",
"oxlint": "^1.83.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"typescript": "^7.0.2",
"vscode-languageserver-protocol": "^3.18.3"
},
"engines": {
"node": ">=26"
},
"allowScripts": {
"lefthook@2.1.14": true
}
}
-295
View File
@@ -1,295 +0,0 @@
# Project Specifications for TypeScript NPM Module
- name of package: tiny-pattern-ts
## 0. References
typescript-lib-starter-tiny => https://github.com/tmueller/typescript-lib-starter-tiny/
## 1. Development Environment
- **TypeScript**: Use strictest rules (via npm package "@tsconfig/strictest", additional strictures)
- **EditorConfig**: Use `.editorconfig` from typescript-lib-starter-tiny
- **Prettier**: For code formatting, integrated with ESLint
- **ESLint**: Strictest type-checked rules, integrated with Prettier
- **Import Sorting**: Via Prettier or ESLint
- **cspell**: Basic spelling configuration
- **Lefthook**: Pre-commit checks for:
- Type checking
- Spelling
- Package sorting
- Linting
- Formatting
- Outdated Packages
- **Additional Dev Dependencies**:
- lefthook
- sort-package-json
- check-outdated
## 2. Build & Test
- **Build Tool**: Vite (ESM only, no CJS)
- **Testing**: Vitest
- **TypeScript Build Output**: `dist` directory
- **Additional Runtime Dependencies**:
- tslib
- type-fest
## 3. Project Structure & Files
- __.gitignore__: Ignore `dist`, `node_modules`, and other common files
- **.npmignore**: Ignore `src` and other non-dist files
- **LICENSE**: MIT
- **README.md**: Scaffolded
- **commit-message-template**: From typescript-lib-starter-tiny
- **Target Environments**: Browser and latest LTS Node.js
- **No React, No CJS, ESM only**
## 4. Automation & Quality
- **Version Automation**: Use standard `npm version` for versioning
- **Unused Dependency Check**: Use `check-outdated` with config
- **No commitlint, no conventional commits**
## 5. Scripts (Clustered as in typescript-lib-starter-tiny, named similarly)
### SETUP
- `use:git-commit-message`: Set up commit message template (if needed)
### TEST
- `test`: Run typecheck and all tests
- `test:unit`: Run unit tests with Vitest
- `test:ci`: Run tests in CI mode (with coverage, fail-fast)
### BUILD
- `build`: Build the project using Vite
### CLEAN
- `clean`: Clean build output
- `clean:build`: Remove dist directory
### CHECK
- `check`: Run all checks (lint, spell, typecheck, import/package sort, outdated)
- `check:eslint`: Run ESLint
- `check:prettier`: Check formatting with Prettier
- `check:cspell`: Run cspell
- `check:tsc`: TypeScript typecheck (no emit)
- `check:package`: Check package.json sort
- `check:outdated`: Check for unused/outdated dependencies
### FIX
- `fix`: Run all fixers (eslint, prettier, package sort)
- `fix:eslint`: Auto-fix ESLint issues
- `fix:prettier`: Auto-fix formatting with Prettier
- `fix:package`: Auto-fix package.json sort
### HOOKS
- Lefthook will run relevant scripts on staged files for pre-commit (typecheck, lint, spell, sort, format, check:outdated)
---
## 6. Repository & CI/CD
- **Repository**: Hosted on GitHub
- **Build Pipeline**: Use GitHub Actions for CI/CD
- On push and pull request: run build, lint, typecheck, test:ci, spell, check:outdated
- On release (tagged commit): publish to npm
## 7. Versioning & Publishing
- **Version Update**: Use `npm version` to bump version after merging to main and before publishing
- **Publishing to npm**: Only publish from CI on tagged commits (e.g., after version bump and release notes)
- **Recommended Workflow**:
1. Develop and merge PRs to main
2. Run all checks via CI
3. Bump version with `npm version <patch|minor|major>`
4. Push tag to GitHub
5. CI builds and publishes to npm on tag
## 8. NPM Keywords
- pattern-matching
- pattern
- match
- algebraic-data-types
- adt
- typescript
> The library is for pattern matching (not regex), similar to F#'s pattern matching, for TypeScript/ESM environments.
## 9. Code Coverage
- **Configuration**:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'test/**'],
},
},
});
```
- Coverage reports: text summary, HTML, and lcov formats
- Add coverage thresholds if desired
- **CI**: Ensure coverage is generated and optionally uploaded as an artifact or checked for minimum thresholds
## 10. Source Structure & Tree Shaking
- **Source Directory**: All source code resides in `src/` and is exported via `src/index.ts`.
- **Configuration**:
- Ensure `sideEffects: false` in `package.json`
- Use ESM-only exports
- Avoid top-level side effects in modules
- Prefer explicit exports in `index.ts` for best results
- the human will implement the source code
## 11. Included Templates from typescript-lib-starter-tiny
### .editorconfig
```plaintext
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true
quote_type = double
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
[COMMIT_EDITMSG]
max_line_length = 0
```
### commit-message-template
```plaintext
# If applied, this commit will... (Max 50 char)
# Explain why this change is being made (Max 72 Char) [WHAT and WHY vs HOW]
# Provide links or keys to any relevant tickets, articles or other resources
Resolves #...
# --- COMMIT END ---
# Remember to
# Use the imperative mood in the subject line
# Capitalize the subject line
# Do not end the subject line with a period
# Separate subject from body with a blank line
# Use the body to explain what and why vs. how
# Can use multiple lines with "-" for bullet points in body
```
### README.md Structure (to scaffold)
- Project title and description
- Development
- Build: `npm run build`
- Test: `npm run test`, `npm run test:ci`
- Checks: `npm run check`, `npm run fix`
- VSCode integration
- Debugging
- Running tests
- Document workflows like
- Version updates
- Changelog automation
- Publishing
- Contribution guidelines
- Commit signing (GPG)
- How to set up commit message template
- Reference to commit-message-template
## 12. Project Initialization & Commit Strategy
- Start by initilizing git with a main branch
- Initial commit: add an empty README.md
- create a feature branch: `feature/setup`
- For each technology or tool added (and its configuration), create a separate commit:
- Prepend each commit message with a matching gitmoji (e.g., :sparkles: for new features, :wrench: for config, etc.)
- Example commit messages:
- :tada: Initial commit with empty README
- :sparkles: Configured Vite (Vite-specific setup)
- :sparkles: Configured TypeScript (may be merged with Vite if dependent)
- :wrench: Configured ESLint
- :wrench: Configured Prettier
- ...and so on for each technology/tool
- Each commit should include only the relevant files and configuration for that technology/tool
- This approach ensures a clean, understandable project history and makes it easy to review or revert specific setup steps
## 13. Changelog Automation
- Use a tool like standard-version or changesets to automate changelog generation from commit messages or PRs.
- Ensure changelog is updated as part of the release process.
## 14. Publishing Public
- Configure npm publishing to be public by default.
- Add `"publishConfig": { "access": "public" }` to package.json.
- Ensure CI/CD pipeline publishes with public access.
## 15. VSCode Integration
- Add a `.vscode/settings.json` with the following content:
```json
{
"typescript.tsdk": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[mdx]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}
}
```
- Add `.vscode/extensions.json` with recommended extensions:
- `esbenp.prettier-vscode` (Prettier)
- `dbaeumer.vscode-eslint` (ESLint)
- `streetsidesoftware.code-spell-checker` (cspell)
- `vitest.explorer` (for test integration)
- `ms-vscode.vscode-typescript-next` (for latest TS features, optional)
- Add `.vscode/tasks.json` for common tasks (optional):
- Build, test, lint, typecheck, format, spell, check:outdated
- Ensure VSCode uses workspace TypeScript version and Prettier for formatting
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
set -eu
# Branch front-door. Run as `npm run create:branch -- <prefix>/<desc>`.
#
# Asserts the branch preconditions — clean tree, no in-progress operation,
# current `main`, green baseline — and only then creates the branch, so the
# expensive `npm run test` is not paid on a tree that was never eligible.
#
# Why the front door exists, the rejected prefix names, and the `create:`
# decision: development/workflow.md § Branching model and § Script prefix
# convention.
BASE="main"
PREFIXES="feature fix chore"
NAME="${1:-}"
if [ -z "${NAME}" ]; then
echo "usage: npm run create:branch -- <prefix>/<desc> (prefix: ${PREFIXES})" >&2
exit 2
fi
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "error: not inside a git work tree." >&2
exit 1
}
MATCH=0
for p in ${PREFIXES}; do
case "${NAME}" in
"${p}/"*) MATCH=1 ;;
esac
done
if [ "${MATCH}" -ne 1 ]; then
echo "error: '${NAME}' must start with one of: ${PREFIXES}." >&2
echo " the prefix is inferred from the task, not defaulted here." >&2
exit 1
fi
git check-ref-format --branch "${NAME}" >/dev/null 2>&1 || {
echo "error: '${NAME}' is not a valid branch name." >&2
exit 1
}
git show-ref --verify --quiet "refs/heads/${NAME}" && {
echo "error: branch '${NAME}' already exists; switch to it instead." >&2
exit 1
}
START_REF=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${START_REF}" ]; then
echo "error: detached HEAD; switch to a branch first." >&2
exit 1
fi
STATE_ROOT=$(git rev-parse --absolute-git-dir)
for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do
[ -e "${STATE_ROOT}/${state}" ] && {
echo "error: a '${state}' operation is in progress; finish or abort it first." >&2
exit 1
}
done
# `--porcelain` is deliberately stricter than `git diff --quiet`: it also reports
# untracked files, which would otherwise ride silently onto the new branch.
DIRTY=$(git status --porcelain)
if [ -n "${DIRTY}" ]; then
echo "error: working tree is not clean:" >&2
echo "${DIRTY}" | sed 's/^/ /' >&2
exit 1
fi
git show-ref --verify --quiet "refs/heads/${BASE}" || {
echo "error: no local '${BASE}' to branch from." >&2
exit 1
}
# Derive the remote rather than hardcoding it: `main` tracks `origin` (ssh)
# here; a hardcoded name would check currency against a ref that may not
# exist on a differently configured clone.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
echo " refusing to branch on a possibly stale '${BASE}'." >&2
exit 1
}
# Only *behind* is a problem. `create:finish` deliberately leaves the local
# merge on '${BASE}' until `create:release` pushes it, so being ahead is the
# normal state between a merge and the release that ships it; branching from
# those commits is intended. Missing remote commits is not.
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
if [ "${BEHIND}" -ne 0 ]; then
echo "error: '${BASE}' is behind '${UPSTREAM}' (by ${BEHIND})." >&2
echo " update it: git switch ${BASE} && git pull --ff-only" >&2
exit 1
fi
else
echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2
fi
restore() {
git switch --quiet "${START_REF}" 2>/dev/null || true
}
trap 'restore' EXIT HUP INT TERM
if [ "${START_REF}" != "${BASE}" ]; then
git switch --quiet "${BASE}"
fi
echo "Baseline: npm run test"
if ! npm run --silent test; then
echo "error: baseline is red on '${BASE}'; fix that first so later failures stay attributable." >&2
exit 1
fi
git switch --quiet --no-track -c "${NAME}"
trap - EXIT HUP INT TERM
# push.default=upstream is set here, so an inherited upstream would make a bare
# `git push` target main. Branching local-from-local does not set one anyway;
# --no-track says so out loud.
echo "Created ${NAME} from ${BASE} $(git rev-parse --short "${BASE}")."
+445
View File
@@ -0,0 +1,445 @@
import fs from "node:fs";
import path from "node:path";
import type { Code, Heading, Paragraph, PhrasingContent, Root } from "mdast";
import { fromMarkdown } from "mdast-util-from-markdown";
/**
* Compile the TypeScript examples embedded in the prose docs into runnable
* tests, so a doc fence that drifts from the API fails CI. Every ```ts fence in
* the scanned Markdown becomes a `test(...)` in a generated
* `src/doc-test/__generated__/<Name>.test.ts`; the file is typechecked by
* `tsc` and executed by `node --test` exactly like a hand-written test.
*
* Usage: node --strip-types scripts/create-doc-tests.ts
*
* Why regenerate-then-typecheck instead of a lint plugin: development/docs.md §
* Validating Markdown code fences. The repo is TypeScript 7 (the native
* compiler), which does not expose the legacy JS compiler API, so the only
* faithful check is to emit real `.ts` files and let the existing `check:tsc` /
* `node --test` pipeline judge them.
*/
/** The package's public entry, as spelled inside the examples. */
const LIBRARY_SOURCE = "tiny-pattern-ts";
/** Matches the library specifier (single- or double-quoted) inside an import. */
const LIBRARY_SPECIFIER = new RegExp(
`(?<quote>["'])${LIBRARY_SOURCE}\\k<quote>`,
"g",
);
/**
* A source specifier resolved to `src/index.ts` by `package.json#imports`.
* Examples import `from "tiny-pattern-ts"`, which neither `node` nor `tsc`
* resolves to source before `dist/` exists, so the generator rewrites it.
*/
const PRELUDE_IMPORT_SOURCE = "#test-tiny-pattern-ts";
/** Markdown files to scan, relative to the repo root. */
const SOURCES = ["README.md", "CONTRIBUTING.md"];
/** Directory the generated tests are written to (gitignored contents). */
const OUTPUT_DIR = "src/doc-test/__generated__";
/** Fences whose info-string matches one of these are compiled; others skipped. */
const TYPESCRIPT_LANGS: ReadonlySet<string> = new Set(["ts", "typescript"]);
/**
* Modules the generated file already imports. An example that imports one of
* these would collide with the prelude binding (duplicate `test` / `assert`), so
* it is surfaced as a fatal error and the example is rewritten.
*/
const PRELUDE_MODULES: ReadonlySet<string> = new Set([
"node:assert",
"node:test",
]);
/** One line of the prelude every generated file starts with. */
const PRELUDE_TEST = 'import { test } from "node:test";';
const PRELUDE_ASSERT = 'import { strict as assert } from "node:assert";';
/** Indentation applied to every fence body line inside the `test` callback. */
const INDENT = " ";
const EMPTY = "";
const INITIAL_COUNT = 0;
const WRITE_INCREMENT = 1;
const FIRST_ITEM = 0;
const FAILURE_EXIT_CODE = 1;
/** Strip all leading blank lines so the import scan starts at real content. */
const LEADING_BLANK_LINES = /^(?:[ \t]*\r?\n)+/;
/** Collapse the plain text of a title paragraph to one line. */
const WHITESPACE = /\s+/g;
/** Split an info-string like `ts title="x"` into its bare language. */
const LANG_SEPARATOR = /\s+/;
/** One leading `import` statement, including a multi-line named import. */
const IMPORT_STATEMENT =
/^import\s+(?:(?:type\s+)?[\w$*{}\s,]+?\s+from\s+)?["'][^"'\n]+["']\s*;?[ \t]*(?:\r?\n|$)/;
/** `import { a, b } from "src";`, with an optional `type` keyword. */
const NAMED_IMPORT =
/^import\s+(?<isType>type\s+)?\{(?<specifiers>[^}]*)\}\s+from\s+["'](?<source>[^"']+)["']\s*;?$/;
/** The module in a `… from "src"` statement. */
const FROM_SOURCE = /from\s+["'](?<source>[^"']+)["']/;
/** The module in a side-effect `import "src"` statement. */
const SIDE_EFFECT_SOURCE = /^import\s+["'](?<source>[^"']+)["']/;
type Block = Root["children"][number];
class DocTestError extends Error {
public constructor(message: string) {
super(message);
this.name = "DocTestError";
}
}
/** Merge named specifiers that target the same module into one import. */
interface NamedImportGroup {
readonly source: string;
readonly isType: boolean;
readonly names: string[];
}
/** One compiled fence, ready to be wrapped in a `test(...)`. */
interface DocCase {
readonly title: string;
readonly body: string;
}
/** Accumulator threaded through the Markdown walk. */
interface BuilderState {
readonly named: Map<string, NamedImportGroup>;
readonly passthrough: Set<string>;
readonly cases: DocCase[];
title: string | undefined;
}
/** Read one capture group, tolerating the type-level `groups` optionality. */
const groupOf = (match: RegExpExecArray, name: string): string | undefined => {
const { groups } = match;
return groups === undefined ? undefined : groups[name];
};
/** Recursively concatenate the literal text of a single inline node. */
const inlineText = (node: PhrasingContent): string => {
if ("value" in node) {
return node.value;
}
if ("children" in node) {
return node.children.map(inlineText).join(EMPTY);
}
return EMPTY;
};
/** Render a paragraph/heading node to its plain text for use as a test title. */
const textOf = (node: Paragraph | Heading): string =>
node.children.map(inlineText).join(EMPTY).replace(WHITESPACE, " ").trim();
/**
* Split a fence into its leading `import` statements and the executable body.
* Only a contiguous run of imports at the very top is hoisted; anything after
* the first non-import line stays in the body verbatim.
*/
const splitImports = (code: string): { imports: string[]; body: string } => {
const imports: string[] = [];
let rest = code.replace(LEADING_BLANK_LINES, EMPTY);
let match = IMPORT_STATEMENT.exec(rest);
while (rest.startsWith("import") && match !== null) {
const statement = match[FIRST_ITEM] ?? EMPTY;
imports.push(statement.trim());
rest = rest.slice(statement.length).replace(LEADING_BLANK_LINES, EMPTY);
match = IMPORT_STATEMENT.exec(rest);
}
return { imports, body: rest.trim() };
};
/** The module an import statement points at, or `undefined` if unreadable. */
const importSource = (statement: string): string | undefined => {
const fromMatch = FROM_SOURCE.exec(statement);
if (fromMatch !== null) {
return groupOf(fromMatch, "source");
}
const sideEffectMatch = SIDE_EFFECT_SOURCE.exec(statement);
return sideEffectMatch === null
? undefined
: groupOf(sideEffectMatch, "source");
};
/**
* Reject an example that imports a harness-provided module, then rewrite the
* library specifier to the source alias so the emitted file resolves.
*/
const normalizeImport = (statement: string): string => {
const source = importSource(statement);
if (source !== undefined && PRELUDE_MODULES.has(source)) {
throw new DocTestError(
`example imports "${source}", which the harness injects; remove the line:\n ${statement.trim()}`,
);
}
return statement.replace(
LIBRARY_SPECIFIER,
`$<quote>${PRELUDE_IMPORT_SOURCE}$<quote>`,
);
};
/** Split the `{ a, b }` contents of a named import into trimmed specifiers. */
const specifiersOf = (raw: string): string[] =>
raw
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > INITIAL_COUNT);
/** Add named specifiers to the group for `(isType, source)`, merging. */
const addNamedImport = (
named: Map<string, NamedImportGroup>,
ref: Pick<NamedImportGroup, "source" | "isType">,
names: readonly string[],
): void => {
const key = `${ref.isType ? "type:" : "value:"}${ref.source}`;
const existing = named.get(key);
if (existing === undefined) {
named.set(key, {
source: ref.source,
isType: ref.isType,
names: [...names],
});
return;
}
for (const name of names) {
if (!existing.names.includes(name)) {
existing.names.push(name);
}
}
};
/** Sort each hoisted import into a merged named group or a passthrough set. */
const collectImports = (
statements: readonly string[],
named: Map<string, NamedImportGroup>,
passthrough: Set<string>,
): void => {
for (const raw of statements) {
const statement = normalizeImport(raw);
const match = NAMED_IMPORT.exec(statement);
if (match === null) {
passthrough.add(statement);
} else {
addNamedImport(
named,
{
source: groupOf(match, "source") ?? EMPTY,
isType: groupOf(match, "isType") !== undefined,
},
specifiersOf(groupOf(match, "specifiers") ?? EMPTY),
);
}
}
};
/** Render the hoisted imports: merged named groups first, then the rest. */
const renderImports = (
named: ReadonlyMap<string, NamedImportGroup>,
passthrough: ReadonlySet<string>,
): string[] => {
const lines: string[] = [];
for (const group of named.values()) {
const keyword = group.isType ? "import type" : "import";
lines.push(
`${keyword} { ${group.names.join(", ")} } from "${group.source}";`,
);
}
for (const statement of passthrough) {
lines.push(statement);
}
return lines;
};
/** The bare language of a fence's info-string, e.g. `ts` in `ts title="x"`. */
const typescriptLang = (code: Code): string =>
(code.lang ?? EMPTY).trim().split(LANG_SEPARATOR)[FIRST_ITEM] ?? EMPTY;
/** Set the title a following fence will inherit. */
const handleTitleNode = (
state: BuilderState,
node: Paragraph | Heading,
): void => {
state.title = textOf(node);
};
/** Report and reset a non-TS fence that was skipped. */
const skipFence = (state: BuilderState, name: string, code: Code): void => {
process.stderr.write(
`${name}: skipped non-TypeScript fence (lang="${code.lang ?? EMPTY}")\n`,
);
state.title = undefined;
};
/** Compile a described TS fence into a case, or reject it. */
const compileFence = (
state: BuilderState,
name: string,
code: Code,
): DocCase => {
const { title } = state;
if (title === undefined) {
const lang = typescriptLang(code);
throw new DocTestError(
`${name}: a \`\`\`${lang} fence has no preceding paragraph or ` +
`heading to use as its test title — describe the example.`,
);
}
const { imports, body } = splitImports(code.value);
collectImports(imports, state.named, state.passthrough);
return { title, body };
};
/** Compile one TS fence into a case, or reject/skip it. */
const handleCodeNode = (
state: BuilderState,
name: string,
code: Code,
): void => {
const lang = typescriptLang(code);
if (!TYPESCRIPT_LANGS.has(lang)) {
skipFence(state, name, code);
return;
}
state.cases.push(compileFence(state, name, code));
};
/** Route one Markdown block, preserving the "described fence" invariant. */
const handleNode = (state: BuilderState, name: string, node: Block): void => {
if (node.type === "paragraph" || node.type === "heading") {
handleTitleNode(state, node);
} else if (node.type === "code") {
handleCodeNode(state, name, node);
} else {
// Only a paragraph/heading introduces a fence; any other block breaks
// the "immediately preceded" chain.
state.title = undefined;
}
};
/** Walk one Markdown file and collect its cases and hoisted imports. */
const parseDoc = (
name: string,
markdown: string,
): Omit<BuilderState, "title"> => {
const state: BuilderState = {
named: new Map(),
passthrough: new Set(),
cases: [],
title: undefined,
};
for (const node of fromMarkdown(markdown).children) {
handleNode(state, name, node);
}
return {
named: state.named,
passthrough: state.passthrough,
cases: state.cases,
};
};
/** Indent a fence body one level for the body of the `test` callback. */
const indentBody = (body: string): string =>
body
.split("\n")
.map((line) =>
line.length > INITIAL_COUNT ? `${INDENT}${line}` : line,
)
.join("\n");
/** Wrap one compiled fence in an executed `test(...)`. */
const renderCase = (docCase: DocCase): string => {
const indented = indentBody(docCase.body);
return `test(${JSON.stringify(docCase.title)}, () => {\n${indented}\n});`;
};
/** Build the prelude + hoisted imports that top every generated file. */
const buildHeader = (name: string, imports: readonly string[]): string[] => {
const header = [
"// Generated by scripts/create-doc-tests.ts — do not edit by hand.",
`// Source: ${name}`,
EMPTY,
PRELUDE_TEST,
PRELUDE_ASSERT,
];
if (imports.length > INITIAL_COUNT) {
header.push(EMPTY, ...imports);
}
header.push(EMPTY);
return header;
};
/** Turn one Markdown file into the source of its generated test file. */
const buildTestFile = (name: string, markdown: string): string => {
const parsed = parseDoc(name, markdown);
if (parsed.cases.length === INITIAL_COUNT) {
return EMPTY;
}
const imports = renderImports(parsed.named, parsed.passthrough);
const header = buildHeader(name, imports);
const blocks = parsed.cases.map(renderCase);
return `${header.join("\n")}\n${blocks.join("\n\n")}\n`;
};
/** Map a source Markdown path to its generated test path under OUTPUT_DIR. */
const outputFor = (source: string): string =>
path.join(
OUTPUT_DIR,
`${path.basename(source, path.extname(source))}.test.ts`,
);
/** Write a generated file when there is content, else clear a stale one. */
const writeIfAny = (dest: string, name: string, out: string): boolean => {
if (out === EMPTY) {
process.stderr.write(`${name}: no TypeScript fences\n`);
fs.rmSync(dest, { force: true });
return false;
}
fs.writeFileSync(dest, out);
return true;
};
/** Generate the doc-test for one source; return whether a file was written. */
const generateFor = (root: string, source: string): boolean => {
const abs = path.join(root, source);
if (!fs.existsSync(abs)) {
process.stderr.write(`skipped missing ${source}\n`);
return false;
}
const name = path.basename(source);
const out = buildTestFile(name, fs.readFileSync(abs, "utf8"));
return writeIfAny(path.join(root, outputFor(source)), name, out);
};
const main = (): void => {
const root = process.cwd();
fs.mkdirSync(path.join(root, OUTPUT_DIR), { recursive: true });
let written = INITIAL_COUNT;
for (const source of SOURCES) {
if (generateFor(root, source)) {
written += WRITE_INCREMENT;
}
}
process.stdout.write(
`created doc-tests for ${written} file(s) under ${OUTPUT_DIR}/\n`,
);
};
try {
main();
} catch (error) {
if (error instanceof DocTestError) {
process.stderr.write(`${error.message}\n`);
process.exitCode = FAILURE_EXIT_CODE;
} else {
throw error;
}
}
+121
View File
@@ -0,0 +1,121 @@
#!/bin/sh
set -eu
# Feature-finish front door. Run as `npm run create:finish`.
#
# Mirror image of `create:branch`: asserts the merge-side preconditions, merges
# the current `feature/`/`fix/`/`chore/` branch into `main` with `--no-ff`,
# proves the result with `npm run verify`, and only then deletes the branch. The
# push is owned by `create:release`, so the merge stays local and reviewable.
# On a conflict it aborts and returns to the feature branch.
#
# Rationale and the rejected alternatives: development/workflow.md § Branching
# model.
BASE="main"
PREFIXES="feature fix chore"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "error: not inside a git work tree." >&2
exit 1
}
STATE_ROOT=$(git rev-parse --absolute-git-dir)
for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do
[ -e "${STATE_ROOT}/${state}" ] && {
echo "error: a '${state}' operation is in progress; finish or abort it first." >&2
exit 1
}
done
# `--porcelain` is deliberately stricter than `git diff --quiet`: it also
# reports untracked files, which would otherwise not be part of the merge and
# silently outlive the branch deletion.
DIRTY=$(git status --porcelain)
if [ -n "${DIRTY}" ]; then
echo "error: working tree is not clean:" >&2
echo "${DIRTY}" | sed 's/^/ /' >&2
exit 1
fi
START_REF=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${START_REF}" ]; then
echo "error: detached HEAD; switch to the branch you want to finish." >&2
exit 1
fi
if [ "${START_REF}" = "${BASE}" ]; then
echo "error: already on '${BASE}'; switch to the branch to finish." >&2
exit 1
fi
MATCH=0
for p in ${PREFIXES}; do
case "${START_REF}" in
"${p}/"*) MATCH=1 ;;
esac
done
if [ "${MATCH}" -ne 1 ]; then
echo "error: '${START_REF}' must start with one of: ${PREFIXES}." >&2
echo " refusing to merge a branch that is not a unit of work." >&2
exit 1
fi
git show-ref --verify --quiet "refs/heads/${BASE}" || {
echo "error: no local '${BASE}' to merge into." >&2
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
# `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on
# failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet
# form prints nothing on failure and the fallback runs.
UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM=""
BEHIND=0
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
echo " refusing to merge onto a possibly stale '${BASE}'." >&2
exit 1
}
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${AHEAD}" -ne 0 ] && [ "${BEHIND}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
echo " reconcile '${BASE}' with '${UPSTREAM}' before finishing." >&2
exit 1
fi
else
echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2
fi
echo "Finishing into ${BASE}:"
git --no-pager log --oneline --no-decorate "${BASE}..${START_REF}" | sed 's/^/ /'
git switch --quiet "${BASE}"
if [ "${BEHIND}" -ne 0 ]; then
echo "Fast-forwarding ${BASE} to ${UPSTREAM} (${BEHIND} commit(s))."
git merge --quiet --ff-only "${UPSTREAM}"
fi
if ! git merge --quiet --no-ff -m ":twisted_rightwards_arrows: Merge ${START_REF} into ${BASE}" "${START_REF}"; then
echo "error: merge of '${START_REF}' failed; aborting and returning to it." >&2
git merge --abort 2>/dev/null || true
git switch --quiet "${START_REF}"
exit 1
fi
echo "Verify: npm run verify"
if ! npm run --silent verify; then
echo "error: 'npm run verify' is red after the merge." >&2
echo " the merge is local and not yet pushed; fix it on '${BASE}' and commit," >&2
echo " then drop the now-merged branch with 'git branch -d ${START_REF}'." >&2
exit 1
fi
git branch --delete "${START_REF}" >/dev/null
echo "Merged ${START_REF} into ${BASE} and deleted the branch."
echo "Next: npm run create:release (or git push, for a merge with no release)."
+147
View File
@@ -0,0 +1,147 @@
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
/**
* Emit precompressed `.br` / `.gz` / `.zst` sidecars next to every text asset
* under the given directories, keeping the originals. The Gitea pages service
* (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the sidecar
* matching `Accept-Encoding` and falls back to the original for the rest.
*
* Usage: node --strip-types scripts/precompress.ts <dir> [<dir>...]
*
* Why sidecars rather than per-request compression: development/ci.md § Coverage
* serving.
*/
/**
* Only extensions worth compressing. Images, fonts and archives are already
* compressed, so a sidecar would only make them bigger.
*/
const TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
".css",
".htm",
".html",
".info",
".js",
".json",
".map",
".md",
".mjs",
".svg",
".txt",
".xml",
".yaml",
".yml",
]);
const GZIP_LEVEL = 9;
const ZSTD_LEVEL = 19;
const INITIAL_COUNT = 0;
/** `process.argv` is `[node, script, ...args]`; drop the first two entries. */
const ARGV_PREFIX_LENGTH = 2;
const FAILURE_EXIT_CODE = 1;
interface Encoder {
readonly suffix: string;
readonly encode: (input: Buffer) => Buffer;
}
const ENCODERS: readonly Encoder[] = [
{
suffix: ".br",
encode: (input) =>
zlib.brotliCompressSync(input, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]:
zlib.constants.BROTLI_MAX_QUALITY,
},
}),
},
{
suffix: ".gz",
encode: (input) => zlib.gzipSync(input, { level: GZIP_LEVEL }),
},
{
suffix: ".zst",
encode: (input) =>
zlib.zstdCompressSync(input, {
params: {
[zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL,
},
}),
},
];
interface Totals {
assets: number;
sidecars: number;
savedBytes: number;
}
/** Depth-first list of every regular file under `directory`, recursively. */
const listFiles = (directory: string): string[] => {
const files: string[] = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
};
const writeSidecar = (
target: string,
input: Buffer,
encoder: Encoder,
): number => {
const compressed = encoder.encode(input);
if (compressed.byteLength < input.byteLength) {
fs.writeFileSync(target, compressed);
return input.byteLength - compressed.byteLength;
}
// Drop a stale sidecar: it would still win at the server.
fs.rmSync(target, { force: true });
return INITIAL_COUNT;
};
const precompress = (file: string, totals: Totals): void => {
if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) {
return;
}
totals.assets += 1;
const input = fs.readFileSync(file);
for (const encoder of ENCODERS) {
const saved = writeSidecar(`${file}${encoder.suffix}`, input, encoder);
if (saved > INITIAL_COUNT) {
totals.sidecars += 1;
totals.savedBytes += saved;
}
}
};
const main = (directories: readonly string[]): void => {
if (directories.length === INITIAL_COUNT) {
process.stderr.write("usage: precompress.ts <dir> [<dir>...]\n");
process.exitCode = FAILURE_EXIT_CODE;
return;
}
const totals: Totals = {
assets: INITIAL_COUNT,
sidecars: INITIAL_COUNT,
savedBytes: INITIAL_COUNT,
};
for (const directory of directories) {
for (const file of listFiles(directory)) {
precompress(file, totals);
}
}
process.stdout.write(
`precompressed ${totals.assets} text assets into ${totals.sidecars} sidecars (saved ${totals.savedBytes} bytes)\n`,
);
};
main(process.argv.slice(ARGV_PREFIX_LENGTH));
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
set -eu
# Print the Keep-a-Changelog section for a release tag, so CI can use it as the
# body of the Gitea release page without re-implementing CHANGELOG parsing.
#
# Run as `scripts/release-notes.sh <tag>`. A leading `v` is tolerated so both
# `v1.2.3` and `1.2.3` match the `## [1.2.3]` heading. Prints to stdout and
# exits non-zero when the tag has no section, so a release can never publish
# with an empty body.
#
# Why: development/publishing.md § Release notes are extracted from the changelog.
TAG="${1:-}"
CHANGELOG="${CHANGELOG:-CHANGELOG.md}"
if [ -z "${TAG}" ]; then
echo "Error: usage: $0 <tag>" >&2
exit 1
fi
if [ ! -f "${CHANGELOG}" ]; then
echo "Error: ${CHANGELOG} not found." >&2
exit 1
fi
# `v1.2.3` and `1.2.3` are the same release; the heading only ever uses the
# bare version.
VERSION="${TAG#v}"
awk -v version="${VERSION}" '
# A new section ends the one we are printing (or is the one we want).
/^## \[/ {
if (found) exit
heading = $0
sub(/^## \[/, "", heading)
sub(/\].*$/, "", heading)
if (heading == version) found = 1
next
}
# Link-reference definitions live at the bottom of the file and are never
# part of the section body. Stopping here keeps the last release notes
# from picking them up (there is no following heading to stop at).
/^\[[^]]+\]:/ { exit }
found {
if ($0 ~ /^[[:space:]]*$/) {
# Buffer blank lines so trailing ones at the end of the section
# are dropped instead of leaking into the release body.
if (started) pending = pending $0 "\n"
} else {
printf "%s%s\n", pending, $0
pending = ""
started = 1
}
}
END {
if (!found) {
printf "Error: no CHANGELOG section for [%s].\n", version > "/dev/stderr"
exit 1
}
}
' "${CHANGELOG}"
+102
View File
@@ -0,0 +1,102 @@
#!/bin/sh
set -eu
# Release front-door. Run as `npm run create:release`.
#
# Graduates the [Unreleased] changelog notes, bumps package.json + the lockfile,
# and commits then tags the exact SHA that CI publishes. The notes are finalized
# in VS Code before pubv because pubv's bump heuristic reads the [Unreleased]
# body.
#
# Tooling rationale, the rejected alternatives, and the version source of truth:
# development/publishing.md.
CHANGELOG="CHANGELOG.md"
BASE="main"
if ! command -v code >/dev/null 2>&1; then
echo "Error: 'code' (VS Code CLI) not found; install it or remove the editor step." >&2
exit 1
fi
# Releases are cut from `main` (see CONTRIBUTING § Publishing workflow). Make
# that explicit rather than relying on pubv's default-branch check, so the
# error names `main` even when the remote's default is configured differently.
CURRENT=$(git symbolic-ref --quiet --short HEAD || true)
if [ "${CURRENT}" != "${BASE}" ]; then
echo "Error: releases are cut from '${BASE}', but HEAD is '${CURRENT:-detached}'." >&2
exit 1
fi
# pubv decides the "default branch" by reading the *local*
# `refs/remotes/origin/HEAD`, not by asking the remote, and `git fetch` never
# updates that ref. After a default-branch change — or a clone from when the
# default was different — it goes stale and pubv warns/fails because the
# current branch (main) does not match it, even though main *is* the remote
# default. Refresh it from the remote first, so pubv's branch preflight
# compares against reality. (Without a network this fails, but so would the
# push pubv is about to do, so it is a real error rather than one to swallow.)
if ! git remote set-head origin --auto >/dev/null 2>&1; then
echo "Error: could not refresh origin/HEAD; check connectivity to origin." >&2
exit 1
fi
# The [Unreleased] body drives pubv's bump heuristic, so finalize it first.
echo "Opening ${CHANGELOG} in VS Code to finalize the release notes..."
code --wait "${CHANGELOG}"
# pubv refuses a dirty tree (its "continue with a dirty tree?" prompt defaults
# to No), so a changed changelog must be committed before it runs. That commit
# is staging only — the fold below rewrites it into the single release commit.
NOTES_MSG=":memo: Finalize release notes"
if [ -n "$(git status --porcelain -- "${CHANGELOG}")" ]; then
echo "Committing finalized release notes..."
git add "${CHANGELOG}"
git commit -m "${NOTES_MSG}"
fi
echo "Running pubv..."
pubv --no-tag --no-push --tag-prefix=none
echo "Reading version from ${CHANGELOG}..."
VERSION=$(
sed -nE 's/^## \[([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' "${CHANGELOG}" |
head -n 1
)
if [ -z "${VERSION}" ]; then
echo "Error: Could not determine release version from ${CHANGELOG}." >&2
exit 1
fi
echo "Release version: ${VERSION}"
echo "Updating package.json and package-lock.json..."
npm version "${VERSION}" --no-git-tag-version
# If pubv's graduation commit sits on top of our staging notes commit, drop it
# back into the index so the amend below rewrites the notes commit into the one
# release commit. A message check, not a flag, so a re-run after pubv aborted
# still folds a notes commit left behind by the earlier attempt.
if [ "$(git log -1 --format=%s HEAD~1 2>/dev/null || true)" = "${NOTES_MSG}" ]; then
git reset --soft HEAD~1
fi
echo "Amending release commit..."
git add package.json package-lock.json "${CHANGELOG}"
# The exact message format is load-bearing: the `release-gate` job in
# .gitea/workflows/ci.yml recognizes `:rocket: Release x.y.z` on main and
# skips the full CI run, since the tag push immediately after verifies the
# identical SHA (and publishes). Keep the two in sync.
git commit --amend -m ":rocket: Release ${VERSION}"
echo "Creating tag ${VERSION}..."
git tag "${VERSION}"
echo "Pushing release..."
git push
git push --tags
echo "Release ${VERSION} completed."
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
# Build (and optionally push) the CI job image from docker/Dockerfile.
# Run wherever docker + registry credentials live (the runner host, or any
# machine that can reach the registry). The registry/repo below MUST match
# the `container.image` references in .gitea/workflows/ci.yml — the runner
# pulls the image by name.
#
# Usage: scripts/runner-image.sh [--push]
#
# Why the image is baked, its two invariants, and the coordinated Node-bump
# steps: development/ci.md.
IMAGE_REPO="gitea.e1nsnull.de/tmu/act-ci"
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
if [[ ! "${NODE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "error: .node-version must be pinned to an exact x.y.z, got '${NODE_VERSION}'." >&2
echo " setup-node resolves floats like '26' to the latest patch at runtime," >&2
echo " which silently busts the tool-cache entry baked into the image." >&2
exit 1
fi
IMAGE="${IMAGE_REPO}:${NODE_VERSION}"
# --pull: refresh the act base layer so the derivative does not float on an
# aging default image forever (layer dedup keeps this cheap).
docker build --pull --build-arg "NODE_VERSION=${NODE_VERSION}" -t "${IMAGE}" -f docker/Dockerfile .
if [[ "${1:-}" == "--push" ]]; then
docker push "${IMAGE}"
fi
echo "built ${IMAGE}"
echo "reminder: bump container.image in .gitea/workflows/ci.yml to this tag"
+12
View File
@@ -0,0 +1,12 @@
# Generated doc-tests
`__generated__/` is the output of [`scripts/create-doc-tests.ts`](../../scripts/create-doc-tests.ts):
each `*.test.ts` is compiled from the ```ts fences in a prose doc and is
**gitignored**, not edited by hand. `npm run create:doc-tests` regenerates them,
formats them with `oxfmt`, then type-checks them with
[`tsconfig.json`](./tsconfig.json) — the scoped config that relaxes
`noUnusedLocals` so example-only locals (and the injected `assert`) compile.
CI regenerates them in an explicit step before `test:ci`; the fast local `test` /
`verify` tiers do not. See [development/docs.md](../../development/docs.md) for why the
generator is shaped this way.
View File
Whitespace-only changes.
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["__generated__/**/*.test.ts"],
"exclude": []
}
+27
View File
@@ -0,0 +1,27 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./index.ts";
// The published entry point is the barrel (`package.json` exports
// `./dist/index.js`), so every factory must be reachable from here. Importing it
// also loads the module, which is what lets c8's `--all` measure it — see
// development/ci.md § Coverage threshold.
test("index: the public entry point re-exports every matcher factory", () => {
// Assert
expectTypeOf(getPrimitiveUnionMatcher).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcher, "function");
expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction();
assert.equal(typeof getPrimitiveUnionMatcherW, "function");
expectTypeOf(getTaggedUnionMatcher).toBeFunction();
assert.equal(typeof getTaggedUnionMatcher, "function");
expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function");
});
+16
View File
@@ -0,0 +1,16 @@
/**
* The public entry point of `tiny-pattern-ts`.
*
* Exports the four matcher factories and nothing else; the builder types they
* return and the rest of `src/` are implementation detail.
*
* @module
*/
export {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
+102
View File
@@ -0,0 +1,102 @@
/* c8 ignore start -- types only: the module has no runtime image to cover */
import type { IsLiteral, IsNever, ValueOf } from "type-fest";
// The primitive-union and tagged-union matchers differ in their universe, but the
// handler/fallback plumbing is identical; these are the shared pieces. The
// boundary is deliberate: `Handlers`, `Fallback` and `MustBePartial` stay with
// each matcher because they are built from its universe. See development/library.md.
// A handler: one universe member in, one return value out.
export type UnaryFn<T, R> = (shape: T) => R;
// The primitive universe a matcher can discriminate. The primitive-union matcher
// uses it directly; the tagged-union matcher uses it as the set of allowed
// discriminant (`Tag`) values. `boolean` is admitted as the pair `true | false`;
// see README § Caveats for the unsupported members.
export type Matchable = string | number | boolean | null | undefined;
// `boolean`, `null` and `undefined` cannot be property keys, so a mapped type
// over a universe that includes one keys each such member by its
// stringification. `Member` inverts that projection against the universe, so a
// handler callback still receives the *real* member (`true`, not `"true"`).
// Both matchers use the projection: the primitive-union matcher over its
// universe, the tagged-union matcher over a discriminant property's values. See
// README § Caveats for the limits.
export type PatternKey<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T;
// The member(s) of `T` whose `PatternKey` is `K`: the universe-keyed inverse of
// `PatternKey`. The key alone cannot recover the member (`"true"` and `true`
// share it), so the handler parameter is derived from `T` instead. For a
// supported (injective) universe the result is a single member.
export type Member<
T extends Matchable,
K extends PropertyKey,
> = T extends Matchable ? (PatternKey<T> extends K ? T : never) : never;
// The property key a `Matchable` member takes at runtime: booleans, `null` and
// `undefined` stringify, and a numeric literal becomes its decimal string.
export type Stringified<T> = T extends boolean
? T extends true
? "true"
: "false"
: T extends null
? "null"
: T extends undefined
? "undefined"
: T extends number
? `${T}`
: never;
// The members of `T` that are also the stringification of another member, so
// `PatternKey` cannot invert them. `never` means the universe is injective.
export type Collisions<T> = Extract<T, Stringified<T>>;
// Why `T` is not a supported universe, or `never` when it is. A supported
// universe is a finite union of literals with no value/stringification
// collision: only then can `PatternKey` be inverted unambiguously.
export type UnsupportedReason<T extends Matchable> =
IsLiteral<PatternKey<T>> extends true
? [Collisions<T>] extends [never]
? never
: `a value and its stringification collide. Value is "${Collisions<T> & string}"`
: "broad types like string, number and template literals are not supported";
// The diagnostic for an unsupported universe. Extends `HandlerMap` so the
// implementation's `handlers: HandlerMap` stays assignable when the gate is
// intersected into a parameter; the property name is the message.
export type UnsupportedUniverse<Reason extends string> = HandlerMap &
Readonly<Record<`unsupported universe: ${Reason}`, never>>;
// `unknown` for a supported universe (an intersection no-op), the diagnostic
// otherwise. Intersecting rather than branching keeps `R` inference intact.
export type UniverseGate<T extends Matchable> =
IsNever<UnsupportedReason<T>> extends true
? unknown
: UnsupportedUniverse<UnsupportedReason<T>>;
// `Extract` drops optional handlers (`undefined`) so `PatternReturns` also works
// when `P`'s constraint has optional keys.
export type PatternReturns<P> = ReturnType<
Extract<ValueOf<P>, (...args: never[]) => unknown>
>;
// The diagnostic raised when a fallback is supplied for an already-exhaustive
// handler map. Each matcher's `MustBePartial` folds it into `Handled`'s
// constraint so the guard is checked after inference.
export interface RedundantFallback {
readonly "every case is already handled, so the fallback is redundant": never;
}
// The runtime dispatch map the handler maps and fallback erase to.
export type HandlerMap = Record<
string | number,
UnaryFn<never, unknown> | undefined
>;
File diff suppressed because it is too large. Load diff
+145
View File
@@ -0,0 +1,145 @@
import type { Exact } from "type-fest";
import type {
HandlerMap,
Matchable,
Member,
PatternKey,
PatternReturns,
RedundantFallback,
UnaryFn,
UniverseGate,
} from "./matcher-shared.ts";
type Handlers<T extends Matchable, R> = {
[K in PatternKey<T>]: UnaryFn<Member<T, K>, R>;
};
// The fallback is a *second argument*, not a property of the handler map,
// because its parameter is the remainder `Exclude<T, Member<T, keyof Handled>>` and TypeScript
// fixes a property's contextual type before it infers its sibling keys. A later
// argument, by contrast, is contextually typed from inference on an earlier
// one, so the split is what makes the remainder expressible at all.
// See development/library.md.
type Fallback<T extends Matchable, Handled, R> = UnaryFn<
Exclude<T, Member<T, keyof Handled>>,
R
>;
// A fallback is redundant once the handler map covers `T`. The guard is folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; a conditional in the fallback's parameter type is evaluated while
// `Handled` is still its constraint and would reject context-sensitive partial
// maps. That placement also fixes where the diagnostic lands: the constraint
// failure is reported on the argument that inferred `Handled` (the handler
// map), so the required property is spelled as the message instead of relying
// on its position. See development/library.md.
type MustBePartial<T extends Matchable, Handled> =
PatternKey<T> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms: a handler map can otherwise
// carry keys outside `T`.
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface PrimitiveUnionMatcherStrict<T extends Matchable> {
<R>(handlers: Handlers<T, R> & UniverseGate<T>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, R>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & Partial<Handlers<T, R>> & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, R>;
}
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
interface PrimitiveUnionMatcherWidening<T extends Matchable> {
<P extends Exact<Handlers<T, unknown>, P>>(
handlers: P & UniverseGate<T>,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, unknown>>, Handled> &
MustBePartial<T, Handled>,
>(
handlers: Handled & UniverseGate<T>,
fallback: Fallback<T, Handled, R> & UniverseGate<T>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
const dispatch =
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: Matchable): unknown =>
// `handlers[true]` already coerces to the `"true"` property at runtime,
// identical to `handlers[String(shape)]`, so indexing with `shape`
// directly is sound: `shape` is a facade-checked universe member and
// `PatternKey` only ever produces valid property keys. The assertion is
// needed solely because TypeScript forbids indexing with
// `boolean`/`null`/`undefined` (TS2538); it buys the number fast path.
(
handlers[
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as string | number
] ??
fallback ??
(() => {
throw new Error(`Unhandled shape: ${String(shape)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
/**
* Create a matcher for a finite primitive universe, with one common return
* type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and every handler
* returns the same type.
*
* The returned builder takes a handler map keyed by the members; supplying a
* second fallback argument allows a partial map and receives the unhandled
* remainder.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchAnswer = getPrimitiveUnionMatcher<"yes" | "no">();
* const describe = matchAnswer({
* yes: () => "agreed",
* no: () => "declined",
* });
* describe("yes"); // "agreed"
*/
export const getPrimitiveUnionMatcher = <
T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch;
/**
* Create a matcher for a finite primitive universe whose return type is the
* union of every handler's return type.
*
* Use it when the value itself is the union (`"yes" | "no"`) and the handlers
* return different types. The `W` (widening) counterpart of
* {@link getPrimitiveUnionMatcher}; the universe constraint and the optional
* fallback are identical.
*
* @typeParam T - The finite universe of primitive members to match.
* @returns A builder for the handler map, or the handler map plus a fallback.
* @example
* const matchReply = getPrimitiveUnionMatcherW<"yes" | "no">();
* const reply = matchReply({
* yes: () => 1,
* no: () => "declined",
* });
* // reply: (shape: "yes" | "no") => number | string
*/
export const getPrimitiveUnionMatcherW = <
T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch;
-96
View File
@@ -1,96 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
File diff suppressed because it is too large. Load diff
+207
View File
@@ -0,0 +1,207 @@
import type { Exact, SetRequired, UnknownRecord } from "type-fest";
import type {
HandlerMap,
Matchable,
Member,
PatternKey,
PatternReturns,
RedundantFallback,
UnaryFn,
UniverseGate,
} from "./matcher-shared.ts";
// A tagged union is discriminated by one property whose values are the tags (a
// `Matchable`). `string` and `number` tags key a handler map directly;
// `boolean`, `null` and `undefined` are admitted too but are not property keys,
// so they go through the `PatternKey` projection. `symbol` has no literal syntax
// to write a handler under, and `bigint` is not a property key.
// The discriminant values of `T` under `K`. `Extract` keeps the finite literal
// tags and leaves a widened `string`/`number` as itself; a broad tag is then
// rejected by the universe gate.
type Tags<T extends object, K extends keyof T> = Extract<T[K], Matchable>;
// The keys of `T` that can act as a discriminant. `getTaggedUnionMatcher<T>()`
// accepts only these, so the factory rejects a key whose values are not tags.
type Discriminated<T extends object> = {
[K in keyof T]: T[K] extends Matchable ? K : never;
}[keyof T];
// The member(s) of `T` narrowed to the tag(s) `V`. Distributes over `T`, so a
// duplicated tag maps to a union of members rather than silently dropping one.
// A member whose `K` cannot take `V` drops out; the rest have `K` narrowed to
// `Extract<T[K], V>`. `T[K] extends V` keeps an exact member (a discriminated
// union's declared interface) untouched, so only a property that is itself a
// union — or optional — goes through the mapped form. `SetRequired` makes `K`
// required unless `V` admits `undefined`: a defined tag yields `{ type: "x" }`,
// while the `undefined` tag keeps `{ type?: never }` (absence) or
// `{ type?: undefined }` when the property admits an explicit `undefined`.
// Keyed by `PatternKey`, so `boolean`/`null`/`undefined` tags can key a mapped
// type; `Member` inverts the projection against the tag set to recover the tag.
type Narrowed<T extends object, K extends keyof T, V> = T extends object
? [Extract<T[K], V>] extends [never]
? never
: T[K] extends V
? T
: undefined extends V
? { [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] }
: SetRequired<
{ [P in keyof T]: P extends K ? Extract<T[P], V> : T[P] },
K
>
: never;
type MapTaggedUnion<T extends object, K extends keyof T> = {
[P in PatternKey<Tags<T, K>>]: Narrowed<T, K, Member<Tags<T, K>, P>>;
};
type Handlers<T extends object, K extends keyof T, R> = {
[P in PatternKey<Tags<T, K>>]: UnaryFn<MapTaggedUnion<T, K>[P], R>;
};
// The tag values whose `PatternKey` is handled.
type HandledTags<T extends object, K extends keyof T, Handled> = Member<
Tags<T, K>,
keyof Handled
>;
// The fallback is a *second argument*, not a property of the handler map, so
// its parameter can be the remainder the map left uncovered: the members
// narrowed to the tags the map did not handle. See development/library.md.
type Fallback<T extends object, K extends keyof T, Handled, R> = UnaryFn<
Narrowed<T, K, Exclude<Tags<T, K>, HandledTags<T, K, Handled>>>,
R
>;
// A fallback is redundant once the handler map covers every tag of `T`. Folded
// into `Handled`'s own (self-referential) constraint so it is checked *after*
// inference; see the primitive-union matcher for why a conditional in the fallback's
// parameter is evaluated too early.
type MustBePartial<T extends object, K extends keyof T, Handled> =
PatternKey<Tags<T, K>> extends keyof Handled ? RedundantFallback : unknown;
// TypeScript does not apply the excess-property check to a generic constraint,
// so `Exact` restores it for the generic forms.
// Strict returns: one common `R`. Overload order is load-bearing:
// #1 Handlers (first) -> the exhaustive form and the autocomplete popup
// #2 Fallback (last) -> accepts a partial handler map plus a fallback
interface TaggedUnionMatcherStrict<T extends object, K extends keyof T> {
<R>(handlers: Handlers<T, K, R> & UniverseGate<Tags<T, K>>): UnaryFn<T, R>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, R>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled &
Partial<Handlers<T, K, R>> &
UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, R>;
}
// Widened returns: the union of every handler's return type. `P` is inferred
// from the whole handler map, whose closed constraint supplies the
// contextual/autocomplete type.
interface TaggedUnionMatcherWidening<T extends object, K extends keyof T> {
<P extends Exact<Handlers<T, K, unknown>, P>>(
handlers: P & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<P>>;
<
R,
Handled extends Exact<Partial<Handlers<T, K, unknown>>, Handled> &
MustBePartial<T, K, Handled>,
>(
handlers: Handled & UniverseGate<Tags<T, K>>,
fallback: Fallback<T, K, Handled, R> & UniverseGate<Tags<T, K>>,
): UnaryFn<T, PatternReturns<Handled> | R>;
}
// The key-taking step of the curried factory. Naming it lets the factory return
// `dispatch` directly, the tacit twin of the primitive-union factory's bare
// `=> dispatch`.
type TaggedUnionMatcherFactory<T extends object> = <K extends Discriminated<T>>(
k: K,
) => TaggedUnionMatcherStrict<T, K>;
type TaggedUnionMatcherWideningFactory<T extends object> = <
K extends Discriminated<T>,
>(
k: K,
) => TaggedUnionMatcherWidening<T, K>;
const dispatch =
(k: PropertyKey) =>
(handlers: HandlerMap, fallback?: UnaryFn<never, unknown>) =>
(shape: object): unknown => {
// `object` carries no index signature, so the read needs the assertion;
// the factory admits only keys whose values are tags, and the map keys
// them by `PatternKey`, so the result is narrowed to the map's key space.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const tag = (shape as UnknownRecord)[k] as string | number;
return (
handlers[tag] ??
fallback ??
(() => {
throw new Error(`Unhandled tag: ${String(tag)}`);
})
)(
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
shape as never,
);
};
/**
* Create a matcher for a discriminated union, with one common return type.
*
* Use it when the value is an object discriminated by a property
* (`{ kind: "circle" } | { kind: "square" }`) and every handler returns the
* same type.
*
* The first call fixes the union `T`; the returned function takes the
* discriminant property's name (`K`, restricted to properties whose values are
* tags), and that returns the handler-map builder. Supplying a second fallback
* argument to the builder allows a partial map and receives the members whose
* tag was not handled. A `boolean`, `null` or `undefined` tag is keyed by its
* stringified form (`true` -> `"true"`); see README § Caveats.
*
* @typeParam T - The discriminated-union type to match.
* @returns A function that takes the discriminant property's name.
* @example
* type Shape =
* | { kind: "circle"; radius: number }
* | { kind: "square"; side: number };
*
* const matchShape = getTaggedUnionMatcher<Shape>()("kind");
* const area = matchShape({
* circle: (s) => Math.PI * s.radius ** 2,
* square: (s) => s.side ** 2,
* });
*/
export const getTaggedUnionMatcher = <
T extends object,
>(): TaggedUnionMatcherFactory<T> => dispatch;
/**
* Create a matcher for a discriminated union whose return type is the union of
* every handler's return type.
*
* Use it when the value is a discriminated object and the handlers return
* different types. The `W` (widening) counterpart of
* {@link getTaggedUnionMatcher}; the curried key step and the optional fallback
* are identical.
*
* @typeParam T - The discriminated-union type to match.
* @returns A function that takes the discriminant property's name.
* @example
* const matchShape = getTaggedUnionMatcherW<Shape>()("kind");
* const describe = matchShape({
* circle: () => "round",
* square: () => 4,
* });
* // describe: (shape: Shape) => string | number
*/
export const getTaggedUnionMatcherW = <
T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch;
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

+147
View File
@@ -0,0 +1,147 @@
import { strict as assert } from "node:assert";
import path from "node:path";
import { test } from "node:test";
import { expectTypeOf } from "expect-type";
import {
type CompletionResult,
type CompletionTarget,
LspSession,
} from "#test-utils/lsp-completion.ts";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../..");
// The `#test-utils/*` self-reference (package.json#imports) is the one route
// scattered test files take to reach test helpers; this pins that it resolves
// and types without starting a language server
// (development/testing.md § Test helpers).
test("test-helper home: `#test-utils/…` resolves to the helper and types it", () => {
// Arrange
const target: CompletionTarget = {
file: "src/util/__tests__/lsp-completion.ts",
source: "",
};
// Assert — no session is constructed: the constructor spawns `tsc --lsp`,
// so only the resolved values and their types are checked here.
expectTypeOf(LspSession).toBeConstructibleWith("repo-root");
expectTypeOf<CompletionResult>().toMatchTypeOf<{
labels: readonly string[];
}>();
assert.equal(typeof LspSession, "function");
assert.equal(target.file, "src/util/__tests__/lsp-completion.ts");
});
// The helper drives a language server, so its contract is asserted against the
// server. Every document below is probed from memory and carries its
// contextual type inline, so the helper is tested without the library's code.
const probe = (source: string, marker?: string): Promise<CompletionResult> => {
const session = new LspSession(REPO_ROOT);
const target: CompletionTarget =
marker === undefined
? { file: "src/__probe.ts", source }
: { file: "src/__probe.ts", source, marker };
return session.completionLabelsAt(target).finally(() => session.close());
};
const HANDLERS = [
"type Handlers = { a: () => number; b: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
];
test("helper: a fresh object literal completes with its contextual keys", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert — the position is the marker's, which the helper strips
expectTypeOf(probed).toEqualTypeOf<Promise<CompletionResult>>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 4 });
assert.deepEqual([...result.labels], ["_?", "a", "b"]);
});
});
test("helper: a handled key drops out of the popup", () => {
// Arrange
const source = [
...HANDLERS,
"const done = apply({",
" b: () => 1,",
" /*COMPLETE*/",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf<CompletionResult["labels"]>().toEqualTypeOf<
readonly string[]
>();
return probed.then((result) => {
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: a custom marker is located at the line start", () => {
// Arrange
const source = [
"type Handlers = { a: () => number; _?: () => number };",
"declare const apply: (handlers: Handlers) => Handlers;",
"const done = apply({",
"@@@",
"});",
"export { done };",
].join("\n");
// Act
const probed = probe(source, "@@@");
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return probed.then((result) => {
assert.deepEqual(result.position, { line: 3, character: 0 });
assert.deepEqual([...result.labels], ["_?", "a"]);
});
});
test("helper: labels are read from the server, not from a pattern literal", () => {
// Arrange
const source = [
'const text = "x";',
"const upper = text./*COMPLETE*/;",
"export { upper };",
].join("\n");
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toHaveProperty("labels");
return probed.then((result) => {
assert.ok(result.labels.includes("toUpperCase"));
});
});
test("helper: a source without the marker rejects", () => {
// Arrange
const source = "const text = 1;\nexport { text };\n";
// Act
const probed = probe(source);
// Assert
expectTypeOf(probed).resolves.toEqualTypeOf<CompletionResult>();
return assert.rejects(probed, /marker not found/);
});
+254
View File
@@ -0,0 +1,254 @@
// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import {
type CompletionItem,
type CompletionList,
CompletionRequest,
ConfigurationRequest,
createMessageConnection,
DidOpenTextDocumentNotification,
InitializedNotification,
InitializeRequest,
type MessageConnection,
ShutdownRequest,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-languageserver-protocol/node";
/**
* Print the completion labels TypeScript's language server offers at a marker
* inside a source file.
*
* Usage: node --strip-types src/util/__tests__/lsp-completion.ts <file> [<marker>]
*
* The marker (default `DEFAULT_MARKER`, a `COMPLETE` block comment) is stripped
* from the source before it is sent; the request position is where the marker
* stood. Point it at a scratch file whose expected type is a matcher pattern to
* inspect the popup.
*
* `LspSession` exposes the same probe to the test suite, so completion is
* asserted against the server rather than the type system.
*
* Why a script and not a type assertion: completion is a contextual-type
* property that `expect-type` cannot observe, and `Parameters<…>` resolves only
* a single overload. The server is the only ground truth. See
* development/testing.md § Autocomplete.
*/
const DEFAULT_MARKER = "/*COMPLETE*/";
const ARGV_PREFIX_LENGTH = 2;
const EXIT_DELAY_MS = 200;
const FAILURE_EXIT_CODE = 1;
const USAGE = "usage: lsp-completion.ts <file> [<marker>]";
export interface Position {
readonly line: number;
readonly character: number;
}
/** A document to probe: `source` carries `marker`, which is stripped. */
export interface CompletionTarget {
/** Path relative to the session's repo root; drives URI and resolution. */
readonly file: string;
readonly source: string;
readonly marker?: string;
}
export interface CompletionResult {
readonly position: Position;
readonly labels: readonly string[];
}
const completionLabels = (
result: CompletionItem[] | CompletionList | null,
): readonly string[] => {
if (result === null) {
return [];
}
const items = Array.isArray(result) ? result : result.items;
return items
.map((item) => item.label)
.toSorted((left, right) => left.localeCompare(right));
};
const markerPosition = (source: string, marker: string): Position => {
const index = source.indexOf(marker);
if (index === -1) {
throw new Error(`marker not found: ${marker}`);
}
const lines = source.slice(0, index).split("\n");
const lineIndex = lines.length - 1;
const line = lines[lineIndex];
return { line: lineIndex, character: line === undefined ? 0 : line.length };
};
/**
* One language server, initialized on first use, shared across probes. Callers
* own the lifecycle and must `close()` it.
*/
export class LspSession {
readonly #child: ChildProcessWithoutNullStreams;
readonly #connection: MessageConnection;
readonly #repoRoot: string;
#ready: Promise<void> | undefined;
public constructor(repoRoot: string) {
this.#repoRoot = repoRoot;
this.#child = spawn(
path.join(repoRoot, "node_modules", ".bin", "tsc"),
["--lsp", "--stdio"],
{ cwd: repoRoot },
);
this.#child.stderr.on("data", (chunk: Buffer) => {
process.stderr.write(chunk);
});
this.#connection = createMessageConnection(
new StreamMessageReader(this.#child.stdout),
new StreamMessageWriter(this.#child.stdin),
);
// Server -> client requests the server waits on: answer so it proceeds.
// `workspace/configuration` wants one reply per requested item; a
// catch-all covers the rest (e.g. `client/registerCapability`).
this.#connection.onRequest(ConfigurationRequest.type, ({ items }) =>
items.map(() => null),
);
this.#connection.onRequest(() => null);
this.#connection.listen();
}
public completionLabelsAt(
target: CompletionTarget,
): Promise<CompletionResult> {
return this.#ensureInitialized()
.then(() => this.#open(target))
.then(({ uri, position }) =>
this.#connection
.sendRequest(CompletionRequest.type, {
textDocument: { uri },
position,
context: { triggerKind: 1 },
})
.then((result) => ({
position,
labels: completionLabels(result),
})),
);
}
/** `shutdown` + close stdin, then kill the server; safe after a failed probe. */
public close(): Promise<void> {
return (
this.#connection
.sendRequest(ShutdownRequest.type)
// The TS 7 Go server logs a bare `context canceled` to stderr
// when it handles `exit`; EOF on stdin shuts it down cleanly
// (exit 0, no output) instead.
.then(() => {
this.#child.stdin.end();
})
.then(() => delay(EXIT_DELAY_MS))
.finally(() => {
this.#connection.dispose();
this.#child.kill();
})
);
}
#ensureInitialized(): Promise<void> {
this.#ready ??= this.#initialize();
return this.#ready;
}
#initialize(): Promise<void> {
return this.#connection
.sendRequest(InitializeRequest.type, {
processId: process.pid,
rootUri: pathToFileURL(this.#repoRoot).href,
workspaceFolders: [
{ uri: pathToFileURL(this.#repoRoot).href, name: "repo" },
],
capabilities: {
textDocument: {
completion: {
completionItem: { snippetSupport: false },
},
publishDiagnostics: {},
},
},
})
.then(() =>
this.#connection.sendNotification(
InitializedNotification.type,
{},
),
);
}
#open(target: CompletionTarget): Promise<{
readonly uri: string;
readonly position: Position;
}> {
const absolute = path.resolve(this.#repoRoot, target.file);
const marker = target.marker ?? DEFAULT_MARKER;
const position = markerPosition(target.source, marker);
const text = target.source.replace(marker, "");
const uri = pathToFileURL(absolute).href;
// The server handles `didOpen` in order before the completion request,
// so no settle delay is needed.
return this.#connection
.sendNotification(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
languageId: "typescript",
version: 1,
text,
},
})
.then(() => ({ uri, position }));
}
}
const main = (args: readonly string[]): Promise<void> => {
const [file, markerArgument] = args;
if (file === undefined) {
process.stderr.write(`${USAGE}\n`);
process.exitCode = FAILURE_EXIT_CODE;
return Promise.resolve();
}
const repoRoot = path.resolve(import.meta.dirname, "../../..");
const absolute = path.resolve(repoRoot, file);
const source = fs.readFileSync(absolute, "utf8");
const session = new LspSession(repoRoot);
return session
.completionLabelsAt({
file,
source,
marker: markerArgument ?? DEFAULT_MARKER,
})
.then(({ position, labels }) => {
process.stdout.write(
`\n[${file}] completions @ ${position.line}:${position.character}:\n${labels.join(", ")}\n`,
);
})
.finally(() => session.close());
};
const isEntryPoint = (): boolean => {
const [entry] = process.argv.slice(1, 2);
return entry !== undefined && import.meta.url === pathToFileURL(entry).href;
};
if (isEntryPoint()) {
main(process.argv.slice(ARGV_PREFIX_LENGTH)).catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = FAILURE_EXIT_CODE;
});
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"target": "es2024",
"declaration": true,
"sourceMap": true,
"inlineSources": true,
"outDir": "dist",
"rewriteRelativeImportExtensions": true,
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}
+8 -19
View File
@@ -1,24 +1,13 @@
{
"extends": [
"@tsconfig/node26/tsconfig.json",
"@tsconfig/strictest/tsconfig.json"
],
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
"include": ["src", "scripts"],
"exclude": ["src/doc-test"]
}