26 Commits
Author SHA1 Message Date
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
17 changed files with 1750 additions and 1043 deletions

No files matched your search

+15
View File
@@ -26,6 +26,21 @@ jobs:
name: coverage
path: coverage
# Advisory scans (dead code, dependency freshness). Non-blocking: surfaced on
# the PR for visibility, but must never gate a merge — so continue-on-error and
# intentionally NOT in `publish`'s `needs`.
maintain:
runs-on: ubuntu-latest
continue-on-error: true
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(github.ref, 'refs/tags/')
needs: build
-13
View File
@@ -1,13 +0,0 @@
node_modules/
coverage/
*.log
*.tsbuildinfo
src/
.vscode/
.editorconfig
.oxfmtrc.json
.oxlintrc.json
.node-version
cspell.json
lefthook.yml
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
+2 -5
View File
@@ -22,11 +22,8 @@
"unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off"
},
"env": {
"builtin": true,
"es2024": true,
"node": true
},
"options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true },
"overrides": [
{
"files": ["**/*.test.ts"],
+43
View File
@@ -0,0 +1,43 @@
# 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.
- **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.
- **`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`
- `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. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / MR) 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.
## 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).
- [README.md § Tooling decisions](./README.md#tooling-decisions) — 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).
+10
View File
@@ -0,0 +1,10 @@
# 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]
[Unreleased]: https://gitea.e1nsnull.de/tmu/tiny-pattern-ts
+88
View File
@@ -0,0 +1,88 @@
# Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in [README.md](./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 below so agents and humans don't diverge.
## 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` resolves the `.ts` form at test time; `rewriteRelativeImportExtensions` emits `.js` in `dist/`. "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions)
- **A new `npm run` script must reuse an existing prefix** (`check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new 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 these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions)
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run use:git-commit-message` once after cloning to register it as git's `commit.template`.
Examples from history: `: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 #...`.
## 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. Picking the right prefix documents the script's intended lifecycle:
- `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` adds c8 coverage.
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`; currently a single child (`watch:test`), and a future `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella.
- `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. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow))
A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline.
Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`), plus `verify` — a cross-cutting convenience composing `check` + `test:unit` into one whole-project correctness gate. It deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once. Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of.
## 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/PR | `npm run check` + `npm run test:ci` | ~30s+ |
| CI maintain (auto, non-blocking) | on push/PR | `npm run maintain` — reports, never fails the build | ~10s |
| CI publish (auto) | on tag | `publish:publint` + `publish:attw`, then `npm publish` | ~10s |
### Why these splits?
- **`watch:*` is a manual tier, not a hook.** The developer starts it on demand (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It sits as the earliest tier in the feedback ladder, catching failures the moment a file is saved — before staging, before commit.
- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally scope to staged files via the `LEFTHOOK_FILES` env var convention. They give instant feedback on what you typed.
- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push runs after all commits are made but before the push leaves the machine, catching regressions that span multiple commits.
### Before pushing
Run `npm run verify` — the one-shot correctness gate in the table above. Run `npm run maintain` only on a maintenance / update-deps branch.
## Testing discipline (type-driven)
For this library the types _are_ the feature — narrowing, `exhaustive()` returns, the `Matcher<T>` contract — so a runtime-only test loop would verify the wrong thing. New behavior follows **type-driven development** (in Edwin Brady's sense): _treat the type as the plan for a program, and use the compiler and type checker as your assistant, guiding you to a complete program that satisfies the type_ ([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the `expectTypeOf` assertion, written first. 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.
This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also 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 so both the type check and the runtime assertion pass, never suppress the ones you can't make pass.
## Publishing workflow
Publishing is CI-only by policy. Local `npm publish` is not supported.
1. Develop and merge PRs to `main`.
2. CI runs `npm run check` + `npm run test:ci` on every push and PR — this is the authoritative gate.
3. After all intended changes are on `main`, bump the version locally:
```sh
npm version <patch|minor|major>
```
4. Push the tag to the forge (Gitea):
```sh
git push --follow-tags origin main
```
5. The `publish` CI job runs on the tag: `build` → `publish:publint` → `publish:attw` → `npm publish --access public`. The publish-tier checks must pass before the artifact is published.
+33 -21
View File
@@ -6,20 +6,46 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex).
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch`
- **Checks:** `npm run check`, `npm run fix`
- **Format/Fix:** `npm run fix:oxfmt`, `npm run fix:oxlint`
- **Verify:** `npm run verify` — the definition of done
- **Maintenance:** `npm run maintain` — advisory only
- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint`
What each tier runs, when it fires and what it costs:
[CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). How the
`prefix:` in a script name is chosen:
[§ Script prefix convention](./CONTRIBUTING.md#script-prefix-convention).
### Tooling
- **TypeScript 7** — type checker and build (`tsc`).
- **node --test** + `--experimental-strip-types` — test runner (Node 22.6+, flag dropped on Node 24).
- **c8** — code coverage for `test:ci`.
- **oxlint** — Rust-based linter.
- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go).
- **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`.
- **cspell** — spell checking.
- **publint** — validates `package.json` for ESM publishing correctness. Runs on publish only (in CI), not as part of `npm run check`.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. Runs on publish only with `--profile esm-only` (the package is intentionally ESM-only).
- **lefthook** — git pre-commit hooks.
- **knip** — finds unused dependencies, exports, and files.
- **check-outdated** — reports dependencies behind the registry; it exits non-zero whenever _any_ dependency is outdated.
- **publint** — validates `package.json` for ESM publishing correctness.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios.
- **lefthook** — git hooks.
Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers).
### Tooling decisions
The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones:
- **`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`**; `tsconfig.build.json` extends it to add the emit-only options (`declaration`, `sourceMap`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. This separation lets the editor and CI type-check from one config while the build emits from the other.
- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted `dist/*` output, so consumers see conventional ESM imports.
- **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation.
- **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source.
- **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate.
- **`attw --profile esm-only`** is semantically correct: this package is intentionally ESM-only (no CommonJS shim), so CJS resolution scenarios are out of scope by design, not a bug.
- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end).
- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_.
- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both.
### Requirements
@@ -31,23 +57,9 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex).
- TypeScript 7 is used via the `typescriptteam.native-preview` extension.
- oxc extension provides oxlint squiggles and oxfmt format-on-save.
## Workflows
## Contributing
- Version updates via `npm version`.
- Publishing via GitHub Actions on tagged commits (see `.github/workflows/ci.yml`); the `publish` job runs `publish:publint` and `publish:attw` before `npm publish`.
## Script prefix convention
Script names follow a prefix convention that signals _when_ they run:
- `check:*` — read-only verification. Aggregated by `npm run check`. Used in pre-commit hooks and CI's build job.
- `fix:*` — mutating counterpart of `check:*`. Run individually (no `npm run fix` aggregator by design — fixes should be intentional, not batched).
- `test:*` — test scripts. `npm run test` runs the full suite; `test:unit` / `test:ci` are scope-specific variants.
- `publish:*` — runs only at publish time, in the CI `publish` job (immediately before `npm publish`). There is no local `npm run publish` script — publishing is CI-only by policy.
## Contribution guidelines
For maintainer and contributor docs — the script prefix convention, the feedback-tier system, the rules the tools don't enforce, and the publishing workflow — see [CONTRIBUTING.md](./CONTRIBUTING.md). AI coding agents: your entry point is [AGENTS.md](./AGENTS.md), which points back to CONTRIBUTING.md.
- Commit signing (GPG).
- Set up commit message template: `npm run use:git-commit-message`.
- See `commit-message-template`.
- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases.
+14 -3
View File
@@ -2,8 +2,6 @@
"version": "0.2",
"language": "en",
"words": [
"tslib",
"typefest",
"lefthook",
"oxlint",
"oxfmt",
@@ -18,7 +16,20 @@
"msvc",
"publint",
"attw",
"arethetypeswrong"
"arethetypeswrong",
"knip",
"tsgolint",
"gitea",
"pubv",
"knope",
"runwisp",
"glab",
"postversion",
"Zilla",
"kacl",
"bestikk",
"silverwind",
"idris"
],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
}
+6
View File
@@ -13,3 +13,9 @@ pre-commit:
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:cspell' sh {staged_files}
typecheck:
run: npm run check:tsc
pre-push:
parallel: false
commands:
test:
run: npm test
+1435 -443
View File
File diff suppressed because it is too large. Load diff
+20 -23
View File
@@ -10,13 +10,18 @@
"pattern-matching",
"typescript"
],
"homepage": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts#readme",
"bugs": {
"url": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/tmueller/tiny-pattern-ts.git"
"url": "git+https://gitea.e1nsnull.de/tmu/tiny-pattern-ts.git"
},
"files": [
"dist",
"CHANGELOG.md",
"README.md",
"LICENSE"
],
@@ -35,55 +40,47 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell && npm run check:outdated",
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell",
"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:oxlint": "oxlint ${LEFTHOOK_FILES:-src}",
"check:tsc": "tsc",
"clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
"fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src",
"release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases",
"test": "npm run check:tsc && node --test --strip-types \"src/**/*.test.ts\"",
"test:ci": "c8 --reporter=text --reporter=lcov --reporter=html 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",
"use:git-commit-message": "cp commit-message-template .git/COMMIT_EDITMSG || true"
"use:git-commit-message": "git config commit.template commit-message-template"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.5",
"@runwisp/pubv": "^1.5.1",
"@tsconfig/node26": "^26.0.1",
"@tsconfig/strictest": "^2.0.8",
"@types/node": "^26.4.1",
"c8": "^12.0.0",
"check-outdated": "^3.0.0",
"cspell": "^10.2.1",
"cspell": "^10.2.2",
"expect-type": "1.4.0",
"knip": "^6.34.0",
"lefthook": "^2.1.12",
"oxfmt": "^0.66.0",
"oxlint": "^1.81.0",
"oxlint-tsgolint": "^7.0.2001",
"publint": "^0.3.24",
"tslib": "^2.8.1",
"type-fest": "^5.9.0",
"typescript": "^7.0.2"
},
"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": {
"node": ">=26"
},
-529
View File
@@ -1,529 +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.
- Stylistic rules superseded by oxfmt (oxfmt is the canonical
formatter; these rules either conflict with its output or
duplicate features oxfmt already provides):
- `eslint/one-var` — oxfmt uses comma-joined `const`
declarations; oxlint wanted to split them.
- `import/group-exports` — oxfmt keeps separate `export`
statements as-is.
- `import/exports-last` — statement ordering is up to oxfmt.
- `eslint/sort-imports` — replaced by oxfmt's built-in
`sortImports` (enabled in `.oxfmtrc.json`).
- `import/consistent-type-specifier-style` — inline
`import { type X, Y }` is intentional for grouping.
- `unicorn/prefer-export-from` — conflicts with how the
barrel `src/index.ts` re-exports through `src/match.ts`.
- `typescript/method-signature-style` — method signatures
in `interface` are conventional TS ergonomics.
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
### PREFIX CONVENTION
Script names 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. Picking the right prefix documents the
script's intended lifecycle:
- `check:*` — read-only verification. Aggregated by `npm run check`
(which runs all `check:*` scripts in order). Called from the
lefthook pre-commit hook on staged files, and from the CI build
job on the full project. Read-only; never modifies files.
- `fix:*` — mutating counterpart of a `check:*` script. There is
**no** `npm run fix` aggregator by design: fixes should be
intentional, not batched. Run individually.
- `test:*` — test scripts. `npm run test` is the canonical entry
point (`check:tsc` + unit tests); `test:unit` skips the typecheck
for fast local iteration; `test:ci` adds c8 coverage and is the
CI variant.
- `publish:*` — runs only at publish time, in the CI `publish` job
(immediately before `npm publish`). There is **no** local
`npm run publish` script — publishing is CI-only by policy (see
§7). The `publish:` prefix still documents intent: this script
validates the _publishable artifact_ (e.g., `dist/`) rather than
the source.
A new script should pick the prefix that matches its lifecycle, not
invent a new one. If no existing prefix fits, that is a signal the
script does not belong in the standard pipeline.
### SETUP
- `use:git-commit-message`: Set up commit message template (if needed).
### TEST
- `test`: Run `check:tsc` then `node --test --strip-types "src/**/*.test.ts"`.
The glob is required because Node 26 does not auto-discover test
files in a bare directory argument (`node --test src/` is
interpreted as a module path on Node 26+).
- `test:unit`: Run unit tests with `node --test --strip-types "src/**/*.test.ts"`
(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 is set in `tsconfig.json`).
- `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.)
### PUBLISH
- `publish:publint`: `publint` — runs the pack-and-lint flow against
the current project (uses `npm pack` to validate the actual
publishable artifact against `package.json`'s `files`, `exports`,
`main`, etc.). Requires a fresh `build` to have populated `dist/`.
Called by the CI `publish` job immediately before `npm publish`
(see §6). Not part of `npm run check` and not run on commit:
it validates publishing correctness, not source correctness.
- `publish:attw`: `attw . --pack --profile esm-only` — validates the
emitted `.d.ts` declarations against multiple TypeScript
module-resolution scenarios. Uses `--profile esm-only` because
this package is intentionally ESM-only (no CommonJS shim); CJS
resolution scenarios are explicitly out of scope by design, not
a bug. Called by the CI `publish` job alongside `publish:publint`
and `npm publish` (see §6). Not part of `npm run check` and not
run on commit: same rationale as `publish:publint`.
### 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 run publish:publint` (see
§5 for why this is `publish:` and not `check:`),
`npm run publish:attw`, and `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]": {
"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 TypeScript 7 via the `typescriptteam.native-preview`
extension, with `oxc.oxc-vscode` for formatting and linting.
+66
View File
@@ -0,0 +1,66 @@
#!/bin/sh
set -eu
# Release front-door. Run as `npm run release`.
#
# How we got here (short): we want hand-written Keep-a-Changelog notes, an
# [Unreleased] -> "## [x.y.z] - DATE" graduation, and a tag that marks the exact
# commit on main that gets published. No single tool did BOTH the [Unreleased]
# graduation AND the package.json bump. So split by strength: `pubv` (tiny,
# changelog-driven) owns preflight + the interactive major/minor/patch heuristic
# + graduating/committing CHANGELOG.md (no tag, no push); `npm version` syncs
# package.json + the lockfile; `--amend` folds them into pubv's single commit;
# tag AFTER the amend (so the tag is never orphaned) and push.
#
# Rejected: the conventional-commits family (our history is gitmoji, not
# Conventional; and we want hand-written notes); changesets/rtk (config + a
# heavier version/publish flow that fights our CI-only publish); knope/kacl/
# bestikk (changelog-only — don't bump package.json; plus 5yr/2yr/brand-new
# maintenance); pubv alone (verified it never writes package.json). We also
# tried `versions` (silverwind) — great Gitea support — but pairing it with a
# hand-rolled promote became a ~180-line script we'd have to maintain, which is
# exactly what this ~30-line version replaces.
CHANGELOG="CHANGELOG.md"
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
echo "Running pubv..."
pubv --no-tag --no-push --tag-prefix=none
echo "Opening ${CHANGELOG} in VS Code..."
code --wait "${CHANGELOG}"
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
echo "Amending release commit..."
git add package.json package-lock.json "${CHANGELOG}"
git commit --amend -m ":bookmark: Release ${VERSION}"
echo "Creating tag ${VERSION}..."
git tag "${VERSION}"
echo "Pushing release..."
git push
git push --tags
echo "Release ${VERSION} completed."
+4 -3
View File
@@ -1,3 +1,4 @@
/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync type-assertion library that the type-aware linter misidentifies as a promise */
import { strict as assert } from "node:assert";
import { test } from "node:test";
@@ -27,7 +28,7 @@ test("P.type narrows to the typeof target", () => {
});
test("exhaustive() returns the union of handler return types", () => {
const result = match<"a" | "b">("a" as "a" | "b")
const result = match<"a" | "b">("a")
.with(P.literal("a"), () => 1 as const)
.with(P.literal("b"), () => "two" as const)
.exhaustive();
@@ -37,7 +38,7 @@ test("exhaustive() returns the union of handler return types", () => {
});
test("otherwise() falls back when no case matches", () => {
const result = match<"x" | "y" | "z">("z" as "x" | "y" | "z")
const result = match<"x" | "y" | "z">("z")
.with(P.literal("x"), (v): string => `got ${v}`)
.otherwise((v): string => `fallback ${v}`);
assert.equal(result, "fallback z");
@@ -46,7 +47,7 @@ test("otherwise() falls back when no case matches", () => {
test("exhaustive throws when no case matches", () => {
assert.throws(
() =>
match<"a" | "b" | "c">("c" as "a" | "b" | "c")
match<"a" | "b" | "c">("c")
.with(P.literal("a"), () => "A")
.with(P.literal("b"), () => "B")
.exhaustive(),
+4 -3
View File
@@ -14,7 +14,7 @@ interface MatchBuilder<T, 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)) {
if (matcher.matches(value)) {
return handler(value);
}
}
@@ -28,6 +28,7 @@ const buildMatch = <T, R>(value: T, cases: Cases<R>): MatchBuilder<T, R> => {
): MatchBuilder<T, R | V> {
const nextCases: Cases<R | V> = [
...cases,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
[pattern, handler as (value: unknown) => R | V],
];
return buildMatch(value, nextCases);
@@ -43,8 +44,8 @@ const buildMatch = <T, R>(value: T, cases: Cases<R>): MatchBuilder<T, R> => {
},
otherwise(handler: (value: T) => R): R {
for (const [matcher, run] of cases) {
if (matcher.matches(value as unknown)) {
return run(value) as R;
if (matcher.matches(value)) {
return run(value);
}
}
return handler(value);
+5
View File
@@ -53,12 +53,16 @@ const typeMatcher = <T>(
typeof expected === "object" &&
expected !== null &&
"matches" in expected,
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
keysMatch = <S extends object>(shape: S, candidate: object): boolean => {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
for (const key of Object.keys(shape) as (keyof S)[]) {
if (!(key in candidate)) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const expected = shape[key],
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
actual = candidate[key as keyof object];
if (isNestedMatcher(expected)) {
if (!expected.matches(actual)) {
@@ -78,6 +82,7 @@ const typeMatcher = <T>(
if (typeof value !== "object" || value === null) {
return false;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const candidate = value as S;
if (!keysMatch(shape, candidate)) {
return false;