🔀 Merge feature/setup into main
CI / build (push) Successful in 32s
CI / publish (push) Skipped
CI / maintain (push) Failing after 28s

This commit is contained in:
tmu committed 2026-09-14 11:20:37 +00:00
commit 376eb42bcb
34 files changed
+6692

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# 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
+130
View File
@@ -0,0 +1,130 @@
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:
build:
runs-on: ubuntu-latest
# Bind-mount 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`); `image` is omitted on purpose so the
# runner keeps using its default job image.
container:
volumes:
- /data/gitea-pages:/data/gitea-pages
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
# 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:
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(gitea.ref, 'refs/tags/')
needs: build
runs-on: ubuntu-latest
# The release page is created with the run's automatic Gitea token
# (`github.token`), not `NPM_TOKEN`, so it needs `contents: write`.
permissions:
contents: write
steps:
# Double-gate: publish only runs on a tag *and* aborts here if NPM_TOKEN
# is unset, so a tag push never silently no-ops (or half-publishes). Set
# NPM_TOKEN in the Gitea repo: Settings → Actions → Secrets.
- name: Assert NPM_TOKEN is configured
run: |
if [ -z "${{ secrets.NPM_TOKEN }}" ]; then
echo "::error::NPM_TOKEN secret is not set — refusing to publish."
exit 1
fi
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
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
- uses: https://gitea.com/actions/gitea-release-action@v1
with:
body_path: release-notes.md
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+14
View File
@@ -0,0 +1,14 @@
node_modules
dist
coverage
*.log
*.tsbuildinfo
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/tasks.json
.idea
.DS_Store
+1
View File
@@ -0,0 +1 @@
26
+5
View File
@@ -0,0 +1,5 @@
# Turn the `engines` field from a warning into a gate. By default a Node
# version mismatch is only reported as a notice, so an install run under an
# unpinned Node still succeeds and silently rewrites package-lock.json using
# that older npm's resolution rules. Refuse the install instead.
engine-strict=true
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 4,
"endOfLine": "lf",
"arrowParens": "always",
"sortPackageJson": true,
"sortImports": true,
"ignorePatterns": [
"dist",
"coverage",
"node_modules",
"package-lock.json",
"*.tsbuildinfo"
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc", "import"],
"categories": {
"correctness": "error",
"suspicious": "error",
"perf": "warn",
"style": "warn",
"restriction": "error",
"nursery": "off"
},
"rules": {
"eslint/no-undefined": "off",
"eslint/sort-keys": "off",
"eslint/id-length": "off",
"import/no-named-export": "off",
"eslint/one-var": "off",
"import/group-exports": "off",
"import/exports-last": "off",
"eslint/sort-imports": "off",
"import/consistent-type-specifier-style": "off",
"unicorn/prefer-export-from": "off",
"typescript/method-signature-style": "off"
},
"options": { "typeAware": true },
"env": { "builtin": true, "es2024": true, "node": true },
"overrides": [
{
"files": ["**/*.test.ts"],
"rules": {
"no-unused-expressions": "off",
"no-empty-file": "off",
"import/no-nodejs-modules": "off",
"eslint/no-magic-numbers": "off"
}
},
{
"files": ["scripts/**/*.ts"],
"rules": {
"import/no-nodejs-modules": "off"
}
}
],
"ignorePatterns": ["dist", "node_modules", "coverage"]
}
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+3
View File
@@ -0,0 +1,3 @@
{
"packages": ["npm:@spences10/pi-lsp@0.0.46"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview",
"sandy081.todotasks",
"EditorConfig.EditorConfig"
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[javascriptreact]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[markdown]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[mdx]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"[yaml]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
},
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
+68
View File
@@ -0,0 +1,68 @@
# 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)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. 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.
- **`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 / 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
- **Task with subtasks** (a task that has indented children): 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 on each subtask with commits, 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).
- **Leaf task** (no indented children): implement on the current branch and commit.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
## 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
+100
View File
@@ -0,0 +1,100 @@
# 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` only resolves the `.ts` form at test time; "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions)
- **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). 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 both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (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))
- **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. (see [Branching model](#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. (see [Branching model](#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Editor configuration
`.editorconfig` is for editor compatibility, not a gate — it is a sane fallback for the files oxfmt does not format (shell scripts, dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG` buffer). Where both apply, `.oxfmtrc.json` is authoritative: oxfmt is the formatter, and the overlapping `.editorconfig` keys only keep non-oxfmt editors close to the formatted result.
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step).
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:
- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent.
- `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))
- `setup:*` — one-time configuration of a fresh clone; mutates the local environment (git config, editor settings) rather than the repo source, so it is never part of a hook or CI step. Aggregated by `npm run setup` (the umbrella), run once after cloning.
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. When it genuinely does belong, a new prefix is allowed — but it enters both lists in this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old `use:` prefix was exactly that; it is now retired into `setup:`).
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`, `setup`), plus one convenience that composes across tiers: `verify` — 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 to `main` / tag | `build` job (build + correctness + 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 | Gitea release page (body from CHANGELOG) + `publish:publint` + `publish:attw`, then `npm publish` | ~15s |
### 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.
## Branching model
**GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — Gitea is the lab; when something is tested and ready for production it will be promoted to GitHub.
- **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 (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate.
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
## Publishing workflow
Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`:
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release`. VS Code opens `CHANGELOG.md` to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it.
3. `scripts/release.sh` creates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
4. CI runs on the push (the `build` and `maintain` jobs); the `publish` job then fires on the tag, consuming the `dist/` artifact the `build` job produced. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 tmu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+68
View File
@@ -1 +1,69 @@
# tiny-pattern-ts
Pattern matching for TypeScript/ESM environments (F#-style, not regex).
## Development
- **Build:** `npm run build`
- **Test:** `npm run test`, `npm run test:ci`
- **Watch:** `npm run watch`
- **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`
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** + `--strip-types` — test runner.
- **c8** — code coverage for `test:ci`.
- **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.
- **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.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via `tsc --lsp --stdio`.
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`, `inlineSources`, `outDir`, `target: es2024`, `rewriteRelativeImportExtensions: true`) and to exclude test files. `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so debuggers can map into `src/` without it being shipped; `declarationMap` is intentionally off because a `.d.ts.map` cannot embed source and would dangle. This separation lets the editor and CI type-check from one config while the build emits from the other.
- **`npm run build` first runs a `prebuild` hook that empties `dist/`.** `tsc` does not prune orphaned emit output — dropping `declarationMap`, for example, left stale `*.d.ts.map` files behind — so the build must start from an empty `dist/` to be reproducible. `prebuild` removes only `dist`; the manual `clean` still resets `dist` + `coverage`, so a local coverage report survives a build.
- **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 JavaScript; the emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [Requirements](#requirements)), so no post-processing step is needed.
- **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.
- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package 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` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that. `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a gitignored install cache that pi recreates automatically on a trusted startup (it runs `npm install` for any missing project package), so the cache is deliberately not tracked.
### Requirements
- Node.js >= 26 (engines field; pinned via `.node-version`).
- TypeScript >= 5.0 to consume the published declarations. The emitted `.d.ts` use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; both resolve on TS >= 5.0 in `node10`/`node16`/`nodenext`/`bundler`.
## VSCode integration
- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks).
- TypeScript 7 is used via the `typescriptteam.native-preview` extension.
- oxc extension provides oxlint squiggles and oxfmt format-on-save; `.vscode/settings.json` pins it per language so a user's local `[language]` formatter settings cannot override the project's choice.
## Contributing
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).
- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases.
+47
View File
@@ -0,0 +1,47 @@
Tasks
Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
---
Setup:
✔ Add gitea release page in CI @high @done
☐ Manually verify the Gitea release page on a real tag push (needs main) @high
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high
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
☐ Achieve 100% branch coverage on `src/pattern.ts`
☐ Achieve 100% branch coverage on `src/match.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
Bugs:
Enhancements:
Documentation:
☐ Add usage examples to README.md
☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs
☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation
Maintenance:
☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low
✔ Add a minimal dir-listing webserver to the gitea docker setup (e.g. caddy `file_server browse` reusing the existing reverse proxy, or any single-binary static server, lipanski/docker-static-website) @done (9/13/2026, 9:02:37 PM)
✔ drop the `actions/upload-artifact` coverage step in favour of the shared-dir layout @done (9/13/2026, 10:37:22 PM)
☐ Explore serving coverage for non-tag pushes (e.g. `main/coverage`, PR previews) @low
☐ Manually verify the coverage was created on a real tag push (needs main) @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
+23
View File
@@ -0,0 +1,23 @@
# If applied, this commit will... (Max 50 char)
# Explain why this change is being made (Max 72 Char) [WHAT and WHY vs HOW]
# Provide links or keys to any relevant tickets, articles or other resources
Resolves #...
# --- COMMIT END ---
# Remember to
# Use the imperative mood in the subject line
# Capitalize the subject line
# Do not end the subject line with a period
# Separate subject from body with a blank line
# Use the body to explain what and why vs. how
# Can use multiple lines with "-" for bullet points in body
+40
View File
@@ -0,0 +1,40 @@
{
"version": "0.2",
"language": "en",
"words": [
"lefthook",
"oxlint",
"oxfmt",
"oxc",
"nodenext",
"oxfmtrc",
"oxlintrc",
"EDITMSG",
"typescriptteam",
"gitmoji",
"dbaeumer",
"msvc",
"publint",
"attw",
"arethetypeswrong",
"knip",
"tsgolint",
"tsgo",
"tsserver",
"gitea",
"lipanski",
"pubv",
"knope",
"runwisp",
"glab",
"postversion",
"prebuild",
"Zilla",
"kacl",
"bestikk",
"silverwind",
"idris",
"todotasks"
],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/knip/schema.json",
"entry": ["scripts/*.ts"],
"ignoreDependencies": ["@runwisp/pubv"]
}
+21
View File
@@ -0,0 +1,21 @@
min_version: 2.0.0
pre-commit:
parallel: true
commands:
oxlint:
glob: "*.{ts,tsx,js,jsx,mjs,cjs}"
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxlint' sh {staged_files}
oxfmt:
glob: "*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc,yaml,yml,md,mdx}"
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:oxfmt' sh {staged_files}
cspell:
run: sh -c 'LEFTHOOK_FILES="$*" npm run check:cspell' sh {staged_files}
typecheck:
run: npm run check:tsc
pre-push:
parallel: false
commands:
test:
run: npm test
+5049
View File
File diff suppressed because it is too large. Load diff
+92
View File
@@ -0,0 +1,92 @@
{
"name": "tiny-pattern-ts",
"version": "0.0.0",
"description": "Pattern matching for TypeScript/ESM environments (F#-style, not regex)",
"keywords": [
"adt",
"algebraic-data-types",
"match",
"pattern",
"pattern-matching",
"typescript"
],
"homepage": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts#readme",
"bugs": {
"url": "https://gitea.e1nsnull.de/tmu/tiny-pattern-ts/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://gitea.e1nsnull.de/tmu/tiny-pattern-ts.git"
},
"files": [
"dist",
"CHANGELOG.md",
"README.md",
"LICENSE"
],
"type": "module",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prebuild": "rm -rf dist",
"check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell",
"check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}",
"check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}",
"check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}",
"check:tsc": "tsc",
"clean": "rm -rf dist coverage",
"fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src scripts",
"create:branch": "./scripts/branch.sh",
"create:finish": "./scripts/finish.sh",
"create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases",
"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",
"setup": "npm run setup:git-commit-message",
"setup: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.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",
"typescript": "^7.0.2"
},
"engines": {
"node": ">=26"
},
"allowScripts": {
"lefthook@2.1.12": true
}
}
+152
View File
@@ -0,0 +1,152 @@
#!/bin/sh
set -eu
# Branch front-door. Run as `npm run create:branch -- <prefix>/<desc>`.
#
# How we got here (short): the branching model says every change starts from a
# clean, current `main`, and the type-driven loop only produces trustworthy
# results if the baseline was green *before* the first edit. Both facts were
# prose. Prose rots silently — a rule nobody checks is a suggestion — so the
# precondition became this script: it asserts, then branches, and the branch
# only appears if the assertions passed. Cheap checks run first, `npm run test`
# runs last: the expensive gate is not paid on a tree that was never eligible.
#
# Rejected for the prefix name: `run:` / `perform:` (both mean only "do the
# thing named after them", so every script in the repo would fit under them and
# the taxonomy collapses); `git:` (names the tool, not the lifecycle moment, and
# advertises passthrough aliases); `start:` (describes this half, not the
# release); `cut:` (idiomatic for both, but it needs VCS slang to decode, and a
# signpost that has to be explained is not one); `flow:` (overloaded in a library
# about type-level matching); and the existing families — `check:*` is read-only
# and aggregated by `check`, so CI would run a command that mutates repo state;
# `fix:*`'s review surface is a file diff, not a branch; `maintain:*` is advisory
# and explicitly never a gate.
#
# `create:` was kept because both members really do create something: a branch,
# a release. It was added to both prefix lists in CONTRIBUTING.md in the same
# commit as its first members, because a prefix missing from those lists is
# invisible — which was the `use:` mistake this repo carried in backlog.tasks (since retired into `setup:`).
# There is deliberately no bare `create` aggregator: "run all the workflows"
# describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one.
#
# Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party,
# and it would make branch start pay a build + pack it has no use for). The
# merge half was originally rejected too ("review the diff yourself" is
# judgment), but it now has its own front door — `create:finish` — which owns
# the merge-side preconditions and the post-merge `verify`, so the start half
# does not have to carry that burden.
#
# Every refusal is non-mutating except the baseline test, which runs on `main`
# after we switch there — so a red `main` restores the branch you started on
# rather than stranding you on it.
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: 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=""
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
}
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${BEHIND}" -ne 0 ] || [ "${AHEAD}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
[ "${AHEAD}" -ne 0 ] && echo " not yet pushed commits on '${BASE}': push them, or rebase this work onto them." >&2
[ "${BEHIND}" -ne 0 ] && 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}")."
+136
View File
@@ -0,0 +1,136 @@
#!/bin/sh
set -eu
# Feature-finish front door. Run as `npm run create:finish`.
#
# Why this exists: `create:branch` opens a unit of work, but the close half
# (`git checkout main && git merge --no-ff <branch>`) stayed prose in the
# branching model, so it drifted per contributor and per session. This is the
# mirror image of `create:branch`: it asserts the same preconditions (clean
# tree, no in-progress operation, `main` matching its upstream), merges the
# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with
# `npm run verify`, and only then deletes the branch.
#
# `create:branch`'s comment argued against wrapping the merge as "judgment —
# review the diff yourself". That judgment still lives here, just moved: the
# maintainer reviews the handover *before* invoking this, and the script only
# commits the merge, never the push. The push is owned by `create:release`, so
# the release commit and its tag leave together and a local merge stays
# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff`
# keeps the unit of work visible in `git log`.
#
# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused:
# the tree is clean (checked above) and `main` is not the checked-out branch
# yet, so there is no local state to lose. True divergence (local commits *and*
# upstream commits) is still refused — that needs a human.
#
# On a merge conflict we abort and return to the feature branch, so a failed
# finish never strands you on a half-merged `main`.
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)."
+144
View File
@@ -0,0 +1,144 @@
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>...]
*/
/**
* 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));
+64
View File
@@ -0,0 +1,64 @@
#!/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.
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}"
+114
View File
@@ -0,0 +1,114 @@
#!/bin/sh
set -eu
# Release front-door. Run as `npm run create: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.
#
# The notes are finalized in VS Code *before* pubv: the [Unreleased] body is
# what pubv's bump heuristic reads, so editing afterwards would inform the
# changelog only, not the version choice. pubv refuses a dirty tree, so that
# edit is committed as a staging commit and folded back into the single release
# commit below.
#
# 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"
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}"
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."
+56
View File
@@ -0,0 +1,56 @@
/* 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";
import { expectTypeOf } from "expect-type";
import { type Matcher, P, match } from "./index.ts";
test("match returns a builder", () => {
const builder = match("x");
expectTypeOf(builder).toHaveProperty("with");
expectTypeOf(builder).toHaveProperty("exhaustive");
expectTypeOf(builder).toHaveProperty("otherwise");
});
test("P.literal narrows to its literal type", () => {
const matcher = P.literal("yes");
expectTypeOf(matcher).toMatchTypeOf<Matcher<"yes">>();
assert.equal(matcher.matches("yes"), true);
assert.equal(matcher.matches("no"), false);
});
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")
.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")
.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")
.with(P.literal("a"), () => "A")
.with(P.literal("b"), () => "B")
.exhaustive(),
/no matching case/,
);
});
+2
View File
@@ -0,0 +1,2 @@
export { match, P } from "./match.ts";
export type { Matcher, Pattern } from "./pattern.ts";
+62
View File
@@ -0,0 +1,62 @@
import { P, type Matcher, type Pattern } from "./pattern.ts";
type Cases<R> = readonly (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)) {
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,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
[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)) {
return run(value);
}
}
return handler(value);
},
};
return builder;
};
export const match = <T>(value: T): MatchBuilder<T, never> =>
buildMatch<T, never>(value, []);
export type { Matcher, Pattern };
export { P };
+105
View File
@@ -0,0 +1,105 @@
/**
* 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 };
},
whenMatcher = <T>(
predicate: (value: unknown) => value is T,
): Matcher<T> => ({
matches: predicate,
}),
whenMatcherAny = <T>(
predicate: (value: unknown) => boolean,
): Matcher<T> => ({
matches: (value: unknown): value is T => predicate(value),
}),
isNestedMatcher = (expected: unknown): expected is Matcher<unknown> =>
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)) {
return false;
}
} else if (actual !== expected) {
return false;
}
}
return true;
},
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;
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
const candidate = value as S;
if (!keysMatch(shape, candidate)) {
return false;
}
if (refine && !refine(candidate)) {
return false;
}
return true;
},
});
export const P = {
literal: literalMatcher,
type: typeMatcher,
when: whenMatcher,
any: whenMatcherAny,
shape: structuralMatcher,
} as const;
export type Pattern<T> = Matcher<T>;
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"target": "es2024",
"declaration": true,
"sourceMap": true,
"inlineSources": true,
"outDir": "dist",
"rewriteRelativeImportExtensions": true,
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": [
"@tsconfig/node26/tsconfig.json",
"@tsconfig/strictest/tsconfig.json"
],
"compilerOptions": {
"noEmit": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true
},
"include": ["src", "scripts"]
}