255 Commits
Author SHA1 Message Date
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
50 changed files with 8775 additions and 1334 deletions

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
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
# 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/
# 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/')
needs: build
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
-43
View File
@@ -1,43 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm run check
- run: npm run test:ci
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage
publish:
if: startsWith(github.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
registry-url: "https://registry.npmjs.org/"
- run: npm ci
- run: npm run build
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+4 -16
View File
@@ -1,16 +1,8 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
coverage
node_modules node_modules
dist dist
dist-ssr coverage
*.log
*.tsbuildinfo
*.local *.local
# Editor directories and files # Editor directories and files
@@ -18,10 +10,6 @@ dist-ssr
!.vscode/extensions.json !.vscode/extensions.json
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json
.idea .idea
.DS_Store .DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1 -1
View File
@@ -1 +1 @@
26 26.8.2
-14
View File
@@ -1,14 +0,0 @@
node_modules/
coverage/
*.log
*.tsbuildinfo
src/
.vscode/
.editorconfig
.oxfmtrc.json
.oxlintrc.json
.node-version
cspell.json
lefthook.yml
LICENSE
commit-message-template
+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
+28 -7
View File
@@ -13,13 +13,20 @@
"eslint/no-undefined": "off", "eslint/no-undefined": "off",
"eslint/sort-keys": "off", "eslint/sort-keys": "off",
"eslint/id-length": "off", "eslint/id-length": "off",
"import/no-named-export": "off" "eslint/capitalized-comments": "off",
}, "eslint/no-ternary": "off",
"env": { "import/no-named-export": "off",
"builtin": true, "eslint/one-var": "off",
"es2024": true, "import/group-exports": "off",
"node": true "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": [ "overrides": [
{ {
"files": ["**/*.test.ts"], "files": ["**/*.test.ts"],
@@ -27,7 +34,21 @@
"no-unused-expressions": "off", "no-unused-expressions": "off",
"no-empty-file": "off", "no-empty-file": "off",
"import/no-nodejs-modules": "off", "import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "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"
} }
} }
], ],
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+3
View File
@@ -0,0 +1,3 @@
{
"packages": ["npm:@spences10/pi-lsp@0.0.46"]
}
+4 -1
View File
@@ -1,7 +1,10 @@
{ {
"recommendations": [ "recommendations": [
"connor4312.nodejs-testing",
"oxc.oxc-vscode", "oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview" "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"
}
]
}
+18
View File
@@ -1,12 +1,30 @@
{ {
"nodejs-testing.extensions": [
{
"extensions": ["mjs", "cjs", "js"],
"parameters": []
},
{
"extensions": ["ts"],
"parameters": ["--strip-types"]
}
],
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
}, },
"[javascriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[json]": { "[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode", "editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
+71
View File
@@ -0,0 +1,71 @@
# 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: F#-style 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.
- **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).
+122
View File
@@ -0,0 +1,122 @@
# 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.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.8.1...main
[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
+243
View File
@@ -0,0 +1,243 @@
# 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`
- **Watch:** `npm run watch` - re-runs tests on file save, humans only
- **Checks:** `npm run check`, `npm run fix`
- **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 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).
## 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 mutate git state
rather than the source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half
(maintainer-only). 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:ci` runs the suite under c8 and fails below 100% coverage on `src/`
(CI-only; `verify` stays coverage-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).
+50 -27
View File
@@ -2,41 +2,64 @@
Pattern matching for TypeScript/ESM environments (F#-style, not regex). Pattern matching for TypeScript/ESM environments (F#-style, not regex).
## Development ## Description
- **Build:** `npm run build` `tiny-pattern-ts` brings F#-style pattern matching to TypeScript. Patterns are
- **Test:** `npm run test`, `npm run test:ci` ordinary objects whose `matches` method is a TypeScript type guard, so narrowing
- **Checks:** `npm run check`, `npm run fix` composes the way any other guard does. It is deliberately not a regex engine and
- **Format/Fix:** `npm run fix:oxfmt`, `npm run fix:oxlint` not a macro: there is no transpiler and no DSL to learn, and the type-level
contract is the feature — see [development/library.md](./development/library.md)
for the design decisions and [Caveats](#caveats) for the limits.
### Tooling ## Requirements
- **TypeScript 7** — type checker and build (`tsc`). - **Node.js >= 26** (`engines` field; pinned via `.node-version`).
- **node --test** + `--experimental-strip-types` — test runner (Node 22.6+, flag dropped on Node 24). - **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts`
- **c8** — code coverage for `test:ci`. use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers;
- **oxlint** — Rust-based linter. both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`.
- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`. - The package is **ESM-only** (no CommonJS shim).
- **cspell** — spell checking.
- **lefthook** — git pre-commit hooks.
### Requirements ## API
- Node.js >= 22.6 (>= 24 recommended; `--experimental-strip-types` is unflagged on 24). Yet to be implemented
## VSCode integration ## Caveats
- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell). - **Only finite universes are supported.** The factory must be given a finite
- TypeScript 7 is used via `typescript.tsdk` pointing at `node_modules/typescript/lib`. union of literals; `string`, `number` and template literals are rejected. This
- oxc extension provides oxlint squiggles and oxfmt format-on-save. 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`.
## Workflows ### Why open universes are rejected
- Version updates via `npm version`. An open universe — one carrying a broad member, as in
- Publishing via GitHub Actions on tagged commits (see `.github/workflows/ci.yml`). `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).
## Contribution guidelines ## License
- Commit signing (GPG). MIT © 2025 tmu. See [LICENSE](./LICENSE).
- Set up commit message template: `npm run use:git-commit-message`.
- See `commit-message-template`. ## Contributing
- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases.
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).
+47
View File
@@ -0,0 +1,47 @@
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
v1.0:
☐ API surface is stable and fully typed
☐ Finalize public exports in `src/index.ts`
☐ Document all exported types and functions
☐ Add JSDoc for public APIs
✔ Test coverage meets threshold @done
✔ Achieve 100% branch coverage on `src/primitive-union.ts` @done
✔ Achieve 100% branch coverage on `src/index.ts` @done
Matcher:
✔ when using a union type as a property, the current behavior of tagged union matcher is @done
to pass never to handler parameters
→ new matcher function needed or can be fixed in tagged union matcher
✔ optional discriminant (`{ type?: "x" }`) is the same hole: the boolean/nullish change now admits the `undefined` tag, so the factory accepts the key, but `Extract<T, Record<K, V>>` still passes `never` to both the `x` and `undefined` handlers @done
Documentation:
☐ Bring README.md back to its previous form — synopsis and examples restored, in the correct place
→ previous section order: title, tagline, Synopsis, Description, Requirements, Examples, API, License, Contributing
→ previous Examples order: literal/exhaustive, typeof, structural/discriminated unions, when, any
☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs in Readme.md
☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation
☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API
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 TypeScript 5.0 baseline in CI
+34 -11
View File
@@ -2,8 +2,6 @@
"version": "0.2", "version": "0.2",
"language": "en", "language": "en",
"words": [ "words": [
"tslib",
"typefest",
"lefthook", "lefthook",
"oxlint", "oxlint",
"oxfmt", "oxfmt",
@@ -15,14 +13,39 @@
"typescriptteam", "typescriptteam",
"gitmoji", "gitmoji",
"dbaeumer", "dbaeumer",
"msvc" "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"
], ],
"ignorePaths": [ "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
"dist",
"node_modules",
"public",
"coverage",
"*.svg",
".gitignore"
]
} }
+81
View File
@@ -0,0 +1,81 @@
# 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 |
| [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.
+169
View File
@@ -0,0 +1,169 @@
# 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.
- **`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.
## 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.
+280
View File
@@ -0,0 +1,280 @@
# 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 the
library is placeholder code.
## 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`, raising the consumer floor to
TypeScript 5.4 (README promises `>= 5.0`).
- **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)).
+345
View File
@@ -0,0 +1,345 @@
# 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` lists `src/index.ts` as an entry
#### Decision (2026-09)
`knip.json` declares `"entry": ["src/index.ts", "scripts/*.ts"]`.
#### Why
- Supplying `entry` **replaces** knip's default entry detection, which otherwise
derives the public entry from `package.json` `exports`. Adding `scripts/*.ts`
there therefore dropped the library entry, so knip resolved the package through
its `dist/index.js` output and reported the unreferenced source entry file
`src/index.ts` as an unused file.
- Naming the source entry restores the link between the public API and the
source graph without pointing knip at build output.
#### Rejected
- `paths` mapping `dist/index.*` back to `src/index.ts`: more config to model a
relation the explicit entry states directly, and it would break whenever the
build layout changes.
### `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.46` 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.
+173
View File
@@ -0,0 +1,173 @@
# 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
- Both members create something real: a branch, a release.
- 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": ["src/index.ts", "scripts/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"]
}
+9 -3
View File
@@ -5,11 +5,17 @@ pre-commit:
commands: commands:
oxlint: oxlint:
glob: "*.{ts,tsx,js,jsx,mjs,cjs}" glob: "*.{ts,tsx,js,jsx,mjs,cjs}"
run: sh -c 'LEFTHOOK_FILES="$0" npm run check:oxlint' {staged_files} run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxlint' sh {staged_files}
oxfmt: oxfmt:
glob: "*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,yaml,yml,md,mdx}" glob: "*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,yaml,yml,md,mdx}"
run: sh -c 'LEFTHOOK_FILES="$0" npm run check:oxfmt' {staged_files} run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxfmt' sh {staged_files}
cspell: cspell:
run: sh -c 'LEFTHOOK_FILES="$0" npm run check:cspell' {staged_files} run: sh -c 'LEFTHOOK_FILES="$*" npm run check:cspell' sh {staged_files}
typecheck: typecheck:
run: npm run check:tsc run: npm run check:tsc
pre-push:
parallel: false
commands:
test:
run: npm test
+2275 -475
View File
File diff suppressed because it is too large. Load diff
+51 -36
View File
@@ -1,6 +1,6 @@
{ {
"name": "tiny-pattern-ts", "name": "tiny-pattern-ts",
"version": "0.0.0", "version": "0.8.1",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)", "description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [ "keywords": [
"adt", "adt",
@@ -10,20 +10,26 @@
"pattern-matching", "pattern-matching",
"typescript" "typescript"
], ],
"homepage": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts#readme",
"bugs": {
"url": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/issues"
},
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/tmueller/tiny-pattern-ts.git" "url": "git+https://gitea.e1nsnull.de/tmu/tiny-pattern-ts.git"
}, },
"files": [ "files": [
"dist", "dist",
"CHANGELOG.md",
"README.md", "README.md",
"LICENSE" "LICENSE"
], ],
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"main": "./dist/index.js", "imports": {
"types": "./dist/index.d.ts", "#test-utils/*": "./src/util/__tests__/*"
},
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -35,50 +41,59 @@
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"check": "npm run check:oxlint && npm run check:oxfmt && npm run check:tsc && npm run check:cspell && npm run check:outdated", "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:cspell": "cspell lint ${LEFTHOOK_FILES:-.}",
"check:outdated": "check-outdated --ignore-pre-releases --ignore-packages @oxfmt/binding-darwin-arm64,@oxfmt/binding-darwin-x64,@oxfmt/binding-linux-arm64-gnu,@oxfmt/binding-linux-arm64-musl,@oxfmt/binding-linux-x64-gnu,@oxfmt/binding-linux-x64-musl,@oxfmt/binding-win32-x64-msvc,@oxlint/binding-darwin-arm64,@oxlint/binding-darwin-x64,@oxlint/binding-linux-arm64-gnu,@oxlint/binding-linux-arm64-musl,@oxlint/binding-linux-x64-gnu,@oxlint/binding-linux-x64-musl,@oxlint/binding-win32-x64-msvc",
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src}", "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc", "check:tsc": "tsc",
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"", "clean": "rm -rf dist coverage",
"fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src", "fix:oxlint": "oxlint --fix src scripts",
"test": "npm run check:tsc && node --test --strip-types src/", "create:branch": "./scripts/branch.sh",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types src/", "create:finish": "./scripts/finish.sh",
"test:unit": "node --test --strip-types src/", "create:release": "./scripts/release.sh",
"use:git-commit-message": "cp commit-message-template .git/COMMIT_EDITMSG || true" "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": "c8 --all --include \"src/**/*.ts\" --reporter=text --reporter=lcov --reporter=html --100 node --test --strip-types \"src/**/*.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": { "devDependencies": {
"@types/node": "^26.4.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", "c8": "^12.0.0",
"check-outdated": "^3.0.0", "check-outdated": "^3.0.0",
"cspell": "^10.2.1", "cspell": "^10.3.2",
"expect-type": "1.4.0", "expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12", "lefthook": "^2.1.12",
"oxfmt": "^0.66.0", "oxfmt": "^0.70.0",
"oxlint": "^1.81.0", "oxlint": "^1.83.0",
"tslib": "^2.8.1", "oxlint-tsgolint": "^7.0.2001",
"type-fest": "^5.9.0", "publint": "^0.3.24",
"typescript": "^7.0.2" "typescript": "^7.0.2",
}, "vscode-languageserver-protocol": "^3.18.3"
"optionalDependencies": {
"@oxfmt/binding-darwin-arm64": "^0.66.0",
"@oxfmt/binding-darwin-x64": "^0.66.0",
"@oxfmt/binding-linux-arm64-gnu": "^0.66.0",
"@oxfmt/binding-linux-arm64-musl": "^0.66.0",
"@oxfmt/binding-linux-x64-gnu": "^0.66.0",
"@oxfmt/binding-linux-x64-musl": "^0.66.0",
"@oxfmt/binding-win32-x64-msvc": "^0.66.0",
"@oxlint/binding-darwin-arm64": "^1.81.0",
"@oxlint/binding-darwin-x64": "^1.81.0",
"@oxlint/binding-linux-arm64-gnu": "^1.81.0",
"@oxlint/binding-linux-arm64-musl": "^1.81.0",
"@oxlint/binding-linux-x64-gnu": "^1.81.0",
"@oxlint/binding-linux-x64-musl": "^1.81.0",
"@oxlint/binding-win32-x64-msvc": "^1.81.0"
}, },
"engines": { "engines": {
"node": ">=26" "node": ">=26"
},
"allowScripts": {
"lefthook@2.1.14": true
} }
} }
-463
View File
@@ -1,463 +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 practical rules. Inline in `tsconfig.json` (not
via `@tsconfig/strictest`). The rule set is: `strict`, `noImplicitAny`,
`noImplicitThis`, `alwaysStrict`, `strictNullChecks`, `strictFunctionTypes`,
`strictBindCallApply`, `strictPropertyInitialization`, `noImplicitReturns`,
`noFallthroughCasesInSwitch`, `noUncheckedIndexedAccess`, `noImplicitOverride`,
`noUnusedLocals`, `noUnusedParameters`, `forceConsistentCasingInFileNames`,
`isolatedModules`, `verbatimModuleSyntax`.
- **EditorConfig**: Use `.editorconfig` from typescript-lib-starter-tiny.
- **oxfmt**: Rust-based formatter, Prettier-compatible. Replaces Prettier.
Config in `.oxfmtrc.json` (same shape as `.prettierrc`).
- **oxlint**: Rust-based linter. Replaces ESLint. Config in `.oxlintrc.json`
with `typescript`, `unicorn`, `oxc`, `import` plugins. Categories enabled
as errors: `correctness`, `suspicious`, `restriction`. As warnings: `perf`,
`style`. `nursery` is off.
- **oxlint rules disabled by design** (in `.oxlintrc.json`):
- `eslint/no-undefined` — we use `undefined` as the no-match sentinel.
- `eslint/sort-keys` — handler/case order is semantic, not alphabetical.
- `eslint/id-length` — `T`, `R`, `U`, `V` are standard TS generics.
- `import/no-named-export` — false positive on library entry re-exports.
For `*.test.ts` files additionally: `no-unused-expressions` (for
`expectTypeOf(...)` calls), `no-empty-file` (we have a single import
per test file in some cases), `import/no-nodejs-modules` (we use
`node:test`/`node:assert`/`node:fs` intentionally), `eslint/no-magic-numbers`
(literals in tests are fine).
- **Import sorting**: built into oxfmt (no separate plugin).
- **cspell**: Basic spelling configuration. Words dictionary in `cspell.json`
covers tooling names (`oxlint`, `oxfmt`, `oxc`, `nodenext`,
`oxfmtrc`, `oxlintrc`, `EDITMSG`, `typescriptteam`,
`gitmoji`, `dbaeumer`, `msvc`) and a few library names (`tslib`,
`typefest`, `lefthook`).
- **Lefthook**: Pre-commit checks (run in parallel, lefthook v2 schema).
Single source of truth for the underlying commands is in
`package.json#scripts`; the lefthook config only describes _what to
run on which files_. Hooks that should run on staged files
(`oxlint`, `oxfmt`, `cspell`) set the `LEFTHOOK_FILES` env var to
the staged-files list via a `sh -c` wrapper, and the npm script
uses `${LEFTHOOK_FILES:-<default>}` to default to the full project
when invoked manually:
- `pre-commit.commands.oxlint` (glob `*.{ts,tsx,js,jsx,mjs,cjs}`):
`sh -c 'LEFTHOOK_FILES="$0" npm run check:oxlint' {staged_files}`
- `pre-commit.commands.oxfmt` (glob `*.{ts,tsx,js,jsx,mjs,cjs}`):
`sh -c 'LEFTHOOK_FILES="$0" npm run check:oxfmt' {staged_files}`
- `pre-commit.commands.cspell`:
`sh -c 'LEFTHOOK_FILES="$0" npm run check:cspell' {staged_files}`
- `pre-commit.commands.typecheck`:
`npm run check:tsc` (no file args needed)
`outdated` is intentionally NOT a pre-commit check (it can flag
upstream patch releases that aren't actionable locally); it runs in
CI and via the explicit `lefthook run outdated` command.
- **Additional Dev Dependencies**:
- lefthook
- check-outdated
- c8 (coverage for `node --test`)
- expect-type (type-level assertions in tests)
- oxfmt, oxlint (with their native bindings as `optionalDependencies`
so the correct binding is selected per platform automatically)
- typescript (TS 7)
- @types/node
- tslib (available for future runtime helper imports)
- type-fest (utility types)
## 2. Build & Test
- **Build Tool**: TypeScript 7 (`tsc`) — no bundler, no Vite. The build
is a plain `tsc -p tsconfig.build.json` invocation that emits ESM
JavaScript and `.d.ts` declarations to `dist/`. ESM only, no CJS.
- **Testing**: Node's built-in `node --test` with `--strip-types` (Node
22.6+, unflagged on Node 24/26). Test files are co-located with
source as `*.test.ts`. No Vitest.
- **TypeScript Build Output**: `dist` directory (set in
`tsconfig.build.json#outDir`; `tsconfig.json` keeps `outDir` for
editor tooling but excludes tests from emission via
`tsconfig.build.json#exclude`).
- **Import extensions**: Source uses `.ts` extensions in imports
(e.g. `from "./match.ts"`) so `node --strip-types` resolves them
at test time. TypeScript's `rewriteRelativeImportExtensions` (in
`tsconfig.build.json`) rewrites these to `.js` in the emitted
`dist/*` output, so consumers see conventional ESM imports.
- **TypeScript Compiler Options Notes**:
- `allowImportingTsExtensions: true` is set in the root
`tsconfig.json` (works because the root config has `noEmit: true`).
- `rewriteRelativeImportExtensions: true` is set in
`tsconfig.build.json` to rewrite `.ts` to `.js` on emit.
- `module: nodenext`, `moduleResolution: nodenext` for ESM-first
Node packages.
- `verbatimModuleSyntax: true` enforces explicit `import type`.
- `target: es2024`, `lib: ["es2024"]` (Node 26 supports all ES2024
features natively).
- **Additional Dev Dependencies** (for types and tests):
- `tslib` — available for runtime helper imports; not currently
used by source (kept for future use per the original spec).
- `type-fest` — utility types; not currently used by source
(kept for future use per the original spec).
- `@types/node` — types for `node:test`, `node:assert`, `node:fs`.
- **Node Version**: `>=26` (engines field). Pinned via `.node-version`
for fnm/nvm/volta/mise auto-switching and CI
(`actions/setup-node@v4` with `node-version-file: .node-version`).
## 3. Project Structure & Files
- **.gitignore**: Ignore `dist`, `node_modules`, `coverage`, and other
common files.
- **.node-version**: Single line containing the Node major version
(currently `26`). Used by version managers and CI.
- **.npmignore**: Ignore `node_modules/`, `coverage/`, `*.log`,
`*.tsbuildinfo`, `src/`, `.vscode/`, `.editorconfig`,
`.oxfmtrc.json`, `.oxlintrc.json`, `.node-version`, `cspell.json`,
`lefthook.yml`, `commit-message-template`.
(Note: `dist/` is included via `package.json#files`, not by absence
from `.npmignore`.)
- **.oxfmtrc.json**: oxfmt configuration (Prettier-shaped).
- **.oxlintrc.json**: oxlint configuration.
- **.oxlintrc.json + .oxfmtrc.json** replace the old `.eslintrc.cjs`
and `.prettierrc`.
- **LICENSE**: MIT.
- **README.md**: Scaffolded.
- **commit-message-template**: From typescript-lib-starter-tiny.
- **Target Environments**: Node 26 LTS only (no browser target; this
is a pure Node library, no DOM, no DOM lib in tsconfig).
- **No React, No CJS, ESM only**.
## 4. Automation & Quality
- **Version Automation**: Use standard `npm version` for versioning.
- **Unused Dependency Check**: Use `check-outdated` (devDep, runs in CI
and via the explicit `lefthook run outdated` command).
- **No commitlint, no conventional commits**. Commits use gitmoji
prefixes (e.g. `:sparkles:`, `:wrench:`, `:bug:`, `:fire:`,
`:white_check_mark:`, `:tada:`) for at-a-glance categorization.
## 5. Scripts
### SETUP
- `use:git-commit-message`: Set up commit message template (if needed).
### TEST
- `test`: Run `tsc --noEmit` then `node --test --strip-types src/`.
- `test:unit`: Run unit tests with `node --test --strip-types src/`
(no preceding typecheck).
- `test:ci`: Run tests in CI mode with c8 coverage (text + lcov + html
reporters), uploading `coverage/` as an artifact.
### BUILD
- `build`: Build the project using `tsc -p tsconfig.build.json`
(emits `dist/*.js` + `dist/*.d.ts` + sourcemaps, with `.ts`
imports rewritten to `.js`).
### CLEAN
- `clean`: Remove `dist/` via `node -e "fs.rmSync('dist', {recursive:true, force:true})"`.
(Replaces `clean:build` from the original spec — same effect,
no `rimraf` dep needed.)
### CHECK
- `check`: Run all checks in order — `check:oxlint`, `check:oxfmt`,
`check:tsc`, `check:cspell`, `check:outdated`.
- `check:oxlint`: `oxlint ${LEFTHOOK_FILES:-src}` — lints `src/`
by default; when invoked from the lefthook pre-commit hook with
`LEFTHOOK_FILES` set to the staged-files list, lints only those
files. This is the single source of truth for the oxlint command
and is shared between the manual `npm run check` and the pre-commit
hook. oxlint only understands JS/TS-family languages, so config
files (JSON/YAML/Markdown) are intentionally outside its scope;
they are checked only by oxfmt.
- `check:oxfmt`: `oxfmt --check ${LEFTHOOK_FILES:-.}` — formats the
whole project (`.`) by default, including JS/TS, JSON/JSONC,
YAML, Markdown, MDX and other supported file types. From lefthook
pre-commit, the `LEFTHOOK_FILES` env var scopes to the staged
files matching `*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,yaml,yml,md,mdx}`.
Built-in `sortPackageJson: true` keeps `package.json` keys
alphabetized (replaces the former `sort-package-json` tool).
Built-in import sorting (enabled via `sortImports: true`) replaces
any external import-sort plugin.
- `check:tsc`: `tsc --noEmit`.
- `check:cspell`: `cspell lint ${LEFTHOOK_FILES:-.}` — walks the
project root by default; from lefthook, only the staged files.
- `check:outdated`: `check-outdated --ignore-pre-releases --ignore-packages @oxfmt/binding-*,@oxlint/binding-*`. The oxc native bindings are declared as `optionalDependencies` so the correct one is selected per platform automatically; the `*`-platform bindings show as "not installed" on the current platform and are explicitly ignored here.
### FIX
- `fix:oxlint`: `oxlint --fix src`.
- `fix:oxfmt`: `oxfmt ${LEFTHOOK_FILES:-.}` — same scoping as
`check:oxfmt` (whole project by default, staged files from
lefthook). Writes changes in place.
(There is no `fix` aggregator in the scripts; run the `fix:*` scripts
individually.)
### HOOKS
- Lefthook runs the relevant `check:*` scripts on staged files in
parallel for pre-commit. See `lefthook.yml`.
---
## 6. Repository & CI/CD
- **Repository**: Hosted on GitHub.
- **Build Pipeline**: GitHub Actions (`.github/workflows/ci.yml`).
- `build` job on push and pull_request to `main` and on
`workflow_dispatch`. Steps: `actions/checkout@v4`,
`actions/setup-node@v4` (with `node-version-file: .node-version`,
`cache: npm`), `npm ci`, `npm run build`, `npm run check`,
`npm run test:ci`, then upload `coverage/` as an artifact.
- `publish` job: only on `refs/tags/*`, depends on `build`. Steps:
`actions/checkout@v4`, `actions/setup-node@v4` (with
`node-version-file: .node-version` and `registry-url`),
`npm ci`, `npm run build`, `npm publish --access public`
with `NODE_AUTH_TOKEN` from secrets.
## 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.
- **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
- **Tool**: c8 (V8-native coverage, no instrumentation step).
- **Configuration**: c8 has no project config; the report shape is
pinned in the `test:ci` script:
```jsonc
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html node --test --strip-types src/"
```
- **Reporters**: text summary, HTML, and lcov (matching the original
spec).
- **CI**: `coverage/` is uploaded as a workflow artifact via
`actions/upload-artifact@v4` (see `.github/workflows/ci.yml`).
- **Optional**: Coverage thresholds can be added in c8 config when the
library surface stabilizes.
## 10. Source Structure & Tree Shaking
- **Source Directory**: All source code resides in `src/` and is
exported via `src/index.ts`.
- **Configuration**:
- `sideEffects: false` in `package.json` (set).
- ESM-only exports; `package.json#exports` field maps `.` to
`{"types": "./dist/index.d.ts", "import": "./dist/index.js"}`.
- Avoid top-level side effects in modules.
- Explicit re-exports in `index.ts` for best results.
- Source structure (current):
- `src/index.ts` — public barrel.
- `src/match.ts` — `match(value).with(...).exhaustive() / .otherwise(...)` builder.
- `src/pattern.ts` — `P.literal`, `P.type`, `P.when`, `P.any`, `P.shape` constructors and the `Matcher<T>` interface.
- `src/index.test.ts` — runtime + type-level tests using `node --test` + `expect-type`.
- 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 (scaffolded)
- Project title and description
- Development
- Build: `npm run build`
- Test: `npm run test`, `npm run test:ci`
- Checks: `npm run check`, `npm run fix:oxfmt`, `npm run fix:oxlint`
- Tooling section listing TypeScript 7, node --test, c8, oxlint, oxfmt,
cspell, lefthook
- Requirements: Node.js >= 26
- VSCode integration
- Debugging
- Running tests
- oxc.oxc-vscode provides oxlint and oxfmt in-editor
- Workflows
- Version updates via `npm version`
- Publishing via GitHub Actions on tagged commits
- Contribution guidelines
- Commit signing (GPG)
- How to set up commit message template (`npm run use:git-commit-message`)
- Reference to commit-message-template
- Type-level tests use `expect-type`'s `expectTypeOf(...)` inside
`node --test` cases
## 12. Project Initialization & Commit Strategy
- Start by initializing git with a `main` branch.
- Initial commit: empty README + LICENSE.
- 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,
`:bug:` for fixes, `:fire:` for removals,
`:white_check_mark:` for tests, `:tada:` for initial commit).
- Example commit messages used in this project:
- `:tada: Initial commit with empty README`
- `:wrench: Track .vscode/settings.json for workspace settings`
- `:construction_worker: Added GitHub Actions workflow for CI/CD`
- `:test_tube: Added Vitest configuration with coverage` (later removed)
- `:sparkles: Scaffolded src/index.ts entry point for library code`
- `:wrench: Replace Vite/Vitest with TypeScript 7 and node --test`
- `:wrench: Replace ESLint and Prettier with oxlint and oxfmt`
- `:fire: Remove Vite scaffold leftovers`
- `:sparkles: Add initial pattern-matching API`
- `:white_check_mark: Add expect-type for type-level tests`
- `:wrench: Declare Node 26 as the supported runtime`
- `:bug: Use .ts extensions in imports for node --strip-types`
- `:wrench: Remove Prettier from editor formatter config`
- 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
- Not currently configured. The intended workflow, when adopted, is a
changesets-driven release process: feature PRs include a changeset,
which gets consumed by a release workflow, producing a `CHANGELOG.md`
and a version bump on merge to main.
- The changelog should be updated as part of the release process.
## 14. Publishing Public
- npm publishing is configured to be public by default.
- `publishConfig: { "access": "public" }` is set in `package.json`.
- The CI/CD pipeline publishes with `--access public` on tagged commits.
## 15. VSCode Integration
- `.vscode/settings.json` uses `oxc.oxc-vscode` as the default
formatter for `[typescript]`, `[javascript]`, `[json]`, `[jsonc]`,
`[markdown]`, `[mdx]`, and `[yaml]` (oxfmt under the hood).
```json
{
"typescript.tsdk": "node_modules/typescript/lib",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"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
}
```
- `.vscode/extensions.json` recommends:
- `oxc.oxc-vscode` (oxlint + oxfmt, replaces eslint/prettier/vitest)
- `streetsidesoftware.code-spell-checker` (cspell)
- `typescriptteam.native-preview` (TypeScript 7 nightly support;
replaces the older `ms-vscode.vscode-typescript-next`)
```json
{
"recommendations": [
"oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview"
]
}
```
- `.vscode/tasks.json` is not currently provided; common tasks
(build, test, lint, typecheck, format, spell, check:outdated) are
run via the npm scripts in `package.json` from the integrated
terminal.
- VSCode uses the workspace TypeScript version via
`typescript.tsdk` (and the explicit `js/ts.tsdk.path`), with
`oxc.oxc-vscode` for formatting and linting.
+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}")."
+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"
+20 -48
View File
@@ -3,53 +3,25 @@ import { test } from "node:test";
import { expectTypeOf } from "expect-type"; import { expectTypeOf } from "expect-type";
import { match, P, type Matcher } from "./index.ts"; import {
getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./index.ts";
test("match returns a builder", () => { // The published entry point is the barrel (`package.json` exports
const builder = match("x"); // `./dist/index.js`), so every factory must be reachable from here. Importing it
expectTypeOf(builder).toHaveProperty("with"); // also loads the module, which is what lets c8's `--all` measure it — see
expectTypeOf(builder).toHaveProperty("exhaustive"); // development/ci.md § Coverage threshold.
expectTypeOf(builder).toHaveProperty("otherwise"); test("index: the public entry point re-exports every matcher factory", () => {
}); // Assert
expectTypeOf(getPrimitiveUnionMatcher).toBeFunction();
test("P.literal narrows to its literal type", () => { assert.equal(typeof getPrimitiveUnionMatcher, "function");
const matcher = P.literal("yes"); expectTypeOf(getPrimitiveUnionMatcherW).toBeFunction();
expectTypeOf(matcher).toMatchTypeOf<Matcher<"yes">>(); assert.equal(typeof getPrimitiveUnionMatcherW, "function");
assert.equal(matcher.matches("yes"), true); expectTypeOf(getTaggedUnionMatcher).toBeFunction();
assert.equal(matcher.matches("no"), false); assert.equal(typeof getTaggedUnionMatcher, "function");
}); expectTypeOf(getTaggedUnionMatcherW).toBeFunction();
assert.equal(typeof getTaggedUnionMatcherW, "function");
test("P.type narrows to the typeof target", () => {
const matcher = P.type<string>("string");
expectTypeOf(matcher).toMatchTypeOf<Matcher<string>>();
assert.equal(matcher.matches("hi"), true);
assert.equal(matcher.matches(42), false);
});
test("exhaustive() returns the union of handler return types", () => {
const result = match<"a" | "b">("a" as "a" | "b")
.with(P.literal("a"), () => 1 as const)
.with(P.literal("b"), () => "two" as const)
.exhaustive();
expectTypeOf(result).toEqualTypeOf<1 | "two">();
assert.equal(result, 1);
});
test("otherwise() falls back when no case matches", () => {
const result = match<"x" | "y" | "z">("z" as "x" | "y" | "z")
.with(P.literal("x"), (v): string => `got ${v}`)
.otherwise((v): string => `fallback ${v}`);
assert.equal(result, "fallback z");
});
test("exhaustive throws when no case matches", () => {
assert.throws(
() =>
match<"a" | "b" | "c">("c" as "a" | "b" | "c")
.with(P.literal("a"), () => "A")
.with(P.literal("b"), () => "B")
.exhaustive(),
/no matching case/,
);
}); });
+8 -2
View File
@@ -1,2 +1,8 @@
export { match, P } from "./match.ts"; export {
export type { Matcher, Pattern } from "./pattern.ts"; getPrimitiveUnionMatcher,
getPrimitiveUnionMatcherW,
} from "./primitive-union.ts";
export {
getTaggedUnionMatcher,
getTaggedUnionMatcherW,
} from "./tagged-union.ts";
-63
View File
@@ -1,63 +0,0 @@
import { P, type Matcher, type Pattern } from "./pattern.ts";
type Cases<R> = ReadonlyArray<
readonly [Matcher<unknown>, (value: unknown) => R]
>;
interface MatchBuilder<T, R> {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V>;
exhaustive(): R;
otherwise(handler: (value: T) => R): R;
}
const buildMatch = <T, R>(value: T, cases: Cases<R>): MatchBuilder<T, R> => {
const apply = (): R | undefined => {
for (const [matcher, handler] of cases) {
if (matcher.matches(value as unknown)) {
return handler(value);
}
}
return undefined;
};
const builder = {
with<U extends T, V>(
pattern: Matcher<U>,
handler: (value: U) => V,
): MatchBuilder<T, R | V> {
const nextCases: Cases<R | V> = [
...cases,
[pattern, handler as (value: unknown) => R | V],
];
return buildMatch(value, nextCases);
},
exhaustive(): R {
const result = apply();
if (result === undefined) {
throw new Error(
"tiny-pattern-ts: match.exhaustive() called with no matching case",
);
}
return result;
},
otherwise(handler: (value: T) => R): R {
for (const [matcher, run] of cases) {
if (matcher.matches(value as unknown)) {
return run(value) as R;
}
}
return handler(value);
},
};
return builder;
};
export const match = <T>(value: T): MatchBuilder<T, never> =>
buildMatch<T, never>(value, []);
export type { Matcher, Pattern };
export { P };
+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
>;
-89
View File
@@ -1,89 +0,0 @@
/**
* Pattern matching primitives. Each constructor returns a lightweight
* matcher object whose `matches` method returns a type guard.
*/
export interface Matcher<T> {
readonly matches: (value: unknown) => value is T;
}
const literalMatcher = <
const L extends string | number | boolean | null | undefined,
>(
value: L,
): Matcher<L> => ({
matches: (candidate): candidate is L => candidate === value,
});
const typeMatcher = <T>(
type:
| "string"
| "number"
| "boolean"
| "bigint"
| "symbol"
| "undefined"
| "object"
| "function",
): Matcher<T> => {
const matches = (value: unknown): value is T => {
if (type === "undefined") return value === undefined;
if (type === "object")
return (
(typeof value === "object" && value !== null) ||
typeof value === "function"
);
return typeof value === type;
};
return { matches };
};
const whenMatcher = <T>(
predicate: (value: unknown) => value is T,
): Matcher<T> => ({
matches: predicate,
});
const whenMatcherAny = <T>(
predicate: (value: unknown) => boolean,
): Matcher<T> => ({
matches: (value: unknown): value is T => predicate(value),
});
const structuralMatcher = <S extends object, T extends S>(
shape: S,
refine?: (value: S) => value is T,
): Matcher<T> => ({
matches: (value: unknown): value is T => {
if (typeof value !== "object" || value === null) return false;
const candidate = value as S;
for (const key of Object.keys(shape) as Array<keyof S>) {
if (!(key in candidate)) return false;
const expected = shape[key];
const actual = candidate[key];
if (
typeof expected === "object" &&
expected !== null &&
"matches" in expected
) {
const nested = expected as unknown as Matcher<unknown>;
if (!nested.matches(actual)) return false;
} else if (actual !== expected) {
return false;
}
}
return refine
? refine(candidate)
: (true as T extends S ? true : never);
},
});
export const P = {
literal: literalMatcher,
type: typeMatcher,
when: whenMatcher,
any: whenMatcherAny,
shape: structuralMatcher,
} as const;
export type Pattern<T> = Matcher<T>;
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
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,
);
export const getPrimitiveUnionMatcher = <
T extends Matchable,
>(): PrimitiveUnionMatcherStrict<T> => dispatch;
export const getPrimitiveUnionMatcherW = <
T extends Matchable,
>(): PrimitiveUnionMatcherWidening<T> => dispatch;
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
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,
);
};
export const getTaggedUnionMatcher = <
T extends object,
>(): TaggedUnionMatcherFactory<T> => dispatch;
export const getTaggedUnionMatcherW = <
T extends object,
>(): TaggedUnionMatcherWideningFactory<T> => dispatch;
+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;
});
}
+7 -2
View File
@@ -2,9 +2,14 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": false, "noEmit": false,
"target": "es2024",
"declaration": true,
"sourceMap": true,
"inlineSources": true,
"outDir": "dist",
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"rootDir": "src", "rootDir": "src"
"outDir": "dist"
}, },
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
} }
+6 -33
View File
@@ -1,39 +1,12 @@
{ {
"extends": [
"@tsconfig/node26/tsconfig.json",
"@tsconfig/strictest/tsconfig.json"
],
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
"target": "es2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"lib": ["es2024"],
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"alwaysStrict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"declaration": true, "verbatimModuleSyntax": true
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"composite": false,
"types": ["node"]
}, },
"include": ["src"] "include": ["src", "scripts"]
} }