♻️ Tighten the development/ prose

Same decisions, rationale, rejected alternatives and known issues, said with
less padding: ~5,530 -> ~4,730 words (-15%). Every fact from the first draft is
kept; only the wording, duplicated lead-ins and restated context are cut.
This commit is contained in:
tmu committed 2026-09-15 13:56:49 +00:00
1 parent 7ea84b66fa
commit 74c39e1346
7 files changed
+341 -409

No files matched your search

+30 -41
View File
@@ -1,23 +1,18 @@
# Development documentation
These files explain **why** this project works the way it does: the decisions
behind it, what was rejected, and the shortcomings and known issues we carry.
They are written for maintainers and contributors of the project itself.
Why this project works the way it does: the decisions, what was rejected, and
the shortcomings and known issues we carry. Written for maintainers and
contributors.
The actionable rules — how to set up, run, test, and submit — live in
[CONTRIBUTING.md](../CONTRIBUTING.md). This folder is the "why" behind those
rules: read the relevant file here before changing an area, and update it when
a decision changes, rather than leaving an obsolete reason behind. A rule and
its reason drift apart when they live in one place and are maintained in two;
keeping the rule in CONTRIBUTING.md and the reason here keeps each single-homed.
**Each fact is written once**: the actionable rule in CONTRIBUTING.md, the
reason here. Neither restates the other — when the same fact would be useful in
both, one links to the other instead of copying it.
The actionable rules — setup, running, testing, submitting — live in
[CONTRIBUTING.md](../CONTRIBUTING.md). **Each fact is written once**: the rule
there, the reason here; neither restates the other, and where a fact is useful
in both they link. Read the relevant file before changing an area, and when a
rule changes update its rationale here in the same commit.
User-facing documentation lives in [README.md](../README.md). A `docs/` folder
is deliberately not used yet: the convention is that `docs/` is the future
user documentation site, and deploying that site is out of scope. When it
exists, these files stay here — they are not user documentation.
User-facing documentation is [README.md](../README.md). `docs/` is deliberately
unused: that name is reserved for the future user documentation site, and
deploying it is out of scope. These files are not part of that site.
## Layout
@@ -32,15 +27,13 @@ One file per category:
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing |
A category that outgrows one file becomes a folder with an index, but we start
with one file per category because each area is small enough to hold in mind at
once. When a category splits, the links in CONTRIBUTING.md and README.md keep
pointing at the category, not at a single decision.
We start with one file per category so each area stays small enough to hold in
mind; a category that outgrows it becomes a folder with an index, and the links
in CONTRIBUTING.md and README.md point at the category, not a single decision.
## Decision blocks
Every non-obvious choice is recorded as a block inside the relevant category
file, using this shape:
Record every non-obvious choice as a block in the relevant category file:
```md
## Runner image
@@ -65,25 +58,21 @@ of downloading per job.
- recover with `docker rmi <image>`
```
- The date is the month the decision was made, not the date the file was last
edited. It is the anchor a reader uses to tell "this is current" from "this
was current once".
- `Rejected` is what makes the record worth keeping: it stops the project from
re-litigating the same alternatives. An empty `Rejected` usually means the
alternatives were never written down.
- `Known issue` is where shortcomings live. If a caveat is not tied to one
decision, put it under a `## Known issues` section at the end of the file.
- A superseded decision is **replaced in place**, not archived in a second
file — the file always describes the current world, and git history is the
archive.
- The date is the month the decision was made, not when the file was edited —
the anchor for "current" versus "was current once".
- `Rejected` stops the project re-litigating the same alternatives; an empty one
usually means they were never written down.
- `Known issue` is where shortcomings live. A caveat not tied to one decision
goes under a `## Known issues` section at the end of the file.
- Replace a superseded decision in place rather than archiving it; git history
is the archive.
## Adding to these docs
1. Pick the category file for the area you are changing: `library`, `workflow`,
`tooling`, `testing`, `ci`, or `publishing`.
2. Add or update a decision block. Keep the existing text unless the decision
actually changed.
3. If the change alters an actionable rule, update
[CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link the
two. The reverse also holds: never change a rule in CONTRIBUTING.md without
updating its rationale here.
1. Pick the category: `library`, `workflow`, `tooling`, `testing`, `ci`,
`publishing`.
2. Add or update a decision block; keep existing text unless the decision
changed.
3. If an actionable rule changes, update
[CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link.
Never change a rule there without updating its rationale here.
+47 -57
View File
@@ -1,70 +1,63 @@
# CI
The pipeline definition is [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml);
that file, not this prose, is the source of truth for the job graph. This file
records why the pipeline and its runner image are shaped the way they are.
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) is the source of truth for
the job graph; this file records why it is shaped the way it is.
## Pipeline
- **`build`** (push to `main` / tag) — build + correctness + packaging.
- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; it reports
and never fails the build.
- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports,
never fails the build.
- **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`,
then the Gitea release page and `npm publish`. See
[publishing.md](./publishing.md).
- **`release-gate`** — recognizes a `:rocket: Release x.y.z` commit and skips the
`build`/`maintain` jobs, because `create:release` pushes the tag for the exact
same commit right after and the tag run is authoritative. It uses no Node and
stays on the runner's default image.
then the Gitea release page and `npm publish` (see
[publishing.md](./publishing.md)).
- **`release-gate`** — on a `:rocket: Release x.y.z` commit it skips
`build`/`maintain`, because `create:release` pushes the tag for the same commit
right after and the tag run is authoritative. It uses no Node and stays on the
default image.
## Runner image
#### Decision (2026-09)
The `build` / `maintain` / `publish` jobs run in
`gitea.e1nsnull.de/tmu/act-ci:<version>`
([docker/Dockerfile](../docker/Dockerfile)) — the runner's default act image
with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout
`actions/setup-node` probes before downloading.
`build` / `maintain` / `publish` run in `gitea.e1nsnull.de/tmu/act-ci:<version>`
([docker/Dockerfile](../docker/Dockerfile)): the default act image with Node
overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes.
#### Why
- No job pays the ~50 MB Node fetch, because the probe hits the baked entry.
- The image tag must equal the exact version pinned in
[.node-version](../.node-version), and the image is rebuilt only as part of a
Node bump — there is no other trigger.
- Building requires a docker daemon and registry credentials, so it belongs to
no feedback tier. This is why it is deliberately **not** an `npm run` script:
per the [script prefix convention](./workflow.md#script-prefix-convention), no
existing prefix fits and that is the signal.
- The tag must equal the exact [.node-version](../.node-version) pin, and the
image is rebuilt only as part of a Node bump — there is no other trigger.
- Building needs a docker daemon and registry credentials, so it belongs to no
feedback tier. That is why it is **not** an `npm run` script: no
[prefix](./workflow.md#script-prefix-convention) fits, and that is the signal.
#### Rejected
- Downloading Node in every job: the ~50 MB fetch per job was the original
problem.
- Gitea Pages / Codecov for coverage: neither was confirmed available or wanted
- Downloading Node in every job — the ~50 MB fetch was the original problem.
- Gitea Pages / Codecov for coverage — neither was confirmed available or wanted
(see [Coverage serving](#coverage-serving)).
## Bumping Node
Bumping Node is one coordinated change, committed as a unit:
1. Edit [.node-version](../.node-version) to the new exact `x.y.z` — floats like
`26` resolve to the latest patch at runtime and silently bust the baked
entry; [scripts/runner-image.sh](../scripts/runner-image.sh) refuses them.
1. Edit [.node-version](../.node-version) to the exact `x.y.z` — floats like `26`
resolve to the latest patch at runtime and bust the baked entry, so
[scripts/runner-image.sh](../scripts/runner-image.sh) refuses them.
2. `docker login gitea.e1nsnull.de` (user + package/access token), then
`./scripts/runner-image.sh --push` — it reads the version from `.node-version`
and builds/pushes `<IMAGE_REPO>:<version>`.
`./scripts/runner-image.sh --push`, which reads the version and pushes
`<IMAGE_REPO>:<version>`.
3. Repoint the three `container.image` tags in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to the same version.
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to that version.
Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the
per-job download.
## Image invariants
Two invariants the image must satisfy for the `setup-node` probe to hit, both
easy to break:
For the `setup-node` probe to hit, two things must hold — both easy to break:
### The `x64.complete` marker
@@ -75,10 +68,9 @@ Bake a `<version>/<arch>.complete` marker next to the Node directory.
#### Why
- `actions/tool-cache` accepts a cached tool only when
`<version>/<arch>.complete` exists next to the directory (`tc.find()` checks
it). A plausible-looking `node/<version>/x64/` alone is ignored and the
download happens anyway. See the comment in
[docker/Dockerfile](../docker/Dockerfile).
`<version>/<arch>.complete` exists beside it (`tc.find()` checks). A bare
`node/<version>/x64/` is ignored and the download happens anyway. See the
comment in [docker/Dockerfile](../docker/Dockerfile).
### Tag freshness, with force-pull deliberately off
@@ -89,9 +81,8 @@ Leave `act_runner`'s `force_pull` disabled.
#### Why
- The tag encodes only the Node version, and the image is rebuilt only when that
version changes — so the normal flow always yields a new tag and the runner
pulls it.
- Forcing a pull would re-pull the image on every job for no benefit.
changes — so the normal flow always yields a new tag and the runner pulls it.
- Forcing a pull re-pulls the image on every job for no benefit.
#### Rejected
@@ -101,10 +92,10 @@ Leave `act_runner`'s `force_pull` disabled.
#### Known issue
- A `Dockerfile`-only change (like the marker above) re-pushed under an
unchanged tag is invisible to the runner — it keeps the old image while the
registry shows the new digest. If that ever matters, remove the stale tag on
the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not
reach for force-pull.
unchanged tag is invisible to the runner, which keeps the old image while the
registry shows the new digest. Remove the stale tag on the runner host
(`docker rmi gitea.e1nsnull.de/tmu/act-ci:<version>`); do not reach for
force-pull.
## Coverage serving
@@ -115,9 +106,8 @@ CI.
#### Why
- The webserver just exposes the shared directory; the Gitea docker setup reuses
the existing reverse proxy. There is no upload artifact and no external
service.
- The webserver exposes the shared directory and the Gitea docker setup reuses
the existing reverse proxy — no upload artifact, no external service.
- Coverage is written to a shared volume keyed by project and tag (for example
`/docs/tiny-pattern-ts/<tag>/`).
@@ -127,20 +117,20 @@ CI.
#### Known issue
- Coverage is currently served for tag pushes only. Serving it for non-tag
pushes (for example `main/coverage`) is tracked separately.
- Coverage is served for tag pushes only; non-tag pushes (for example
`main/coverage`) are tracked separately.
[scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst`
sidecars next to every text asset under the served directories. 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.
sidecars next to text assets. The Gitea pages service (`static-web-server` with
`SERVER_COMPRESSION_STATIC=true`) serves the sidecar matching `Accept-Encoding`
and falls back to the original.
#### Decision (2026-09)
Precompress text assets into sidecars rather than compressing on each request.
Precompress into sidecars rather than per request.
#### Why
- The files are static and change only on deploy, so the work is paid once.
- Images, fonts and archives are already compressed; a sidecar would only make
them bigger, which is why only text extensions are emitted.
- The assets are static and change only on deploy, so the work is paid once.
- Images, fonts and archives are already compressed; a sidecar would only grow
them, so only text extensions are emitted.
+38 -48
View File
@@ -1,116 +1,106 @@
# Library design
The type-level design of the public API, and the limitations it carries. The
user-facing reference is [README § API](../README.md#api); this file records why
the API is shaped the way it is.
The type-level design of the public API and the limitations it carries. The
user-facing reference is [README § API](../README.md#api).
## A type guard is the single primitive
#### Decision (2026-09)
Every pattern constructor returns a `Matcher<T>` whose only member is
`matches: (value: unknown) => value is T`. `Pattern<T>` is an alias for
`Matcher<T>`.
`matches: (value: unknown) => value is T`; `Pattern<T>` is an alias.
#### Why
- A type guard is the one TypeScript construct that both narrows in an `if` and
composes into a chain, so the whole library can be built from it without a DSL
or transpiler.
- Because `matches` narrows, `match(value).with(pattern, handler)` can give the
handler the right type with no cast and no runtime type tag.
- Keeping `Pattern<T>` as an alias means a caller can name either; the type is
identical.
composes into a chain, so the library needs no DSL and no transpiler.
- Because `matches` narrows, `match(value).with(pattern, handler)` types the
handler with no cast and no runtime tag.
- `Pattern<T>` is an alias, so either name is the same type.
## The builder is immutable
#### Decision (2026-09)
`match(value)` returns a builder whose `.with` returns a _new_ builder rather
than mutating the current one.
`.with` returns a new builder rather than mutating the current one.
#### Why
- A partially built chain can be stored and reused without one call site
affecting another.
- `.with` widens the result type `R` to `R | V`; a value that changes type in
place cannot be represented soundly. A new value per `.with` is what makes the
type-level accumulation work.
- `.with` widens the result type to `R | V`, which cannot be represented by a
value that changes type in place.
## `exhaustive()` is a runtime check
#### Decision (2026-09)
`.exhaustive()` throws at runtime when no case matched. It does not statically
prove that every member of the input union has a case.
`.exhaustive()` throws when no case matched; it does not statically prove that
every member of the input union has a case.
#### Why
- TypeScript cannot require a fluent call chain to cover every union member
without a compiler plugin or a builder whose type tracks an uncovered-member
set. That machinery is out of proportion to the library's size.
- The union-of-return-types (`R`) is still type-safe; what is not guaranteed is
that the chain is complete.
- Proving coverage through a fluent chain would need a compiler plugin or a
builder that tracks an uncovered-member set — out of proportion to this
library's size.
- The union of handler return types is still type-safe; only the chain's
completeness is unchecked.
#### Known issue
- A missing case is a runtime `Error`, not a compile error. A caller who wants
compile-time safety must either add a `.otherwise(...)` (which never throws) or
prove coverage themselves. Making the builder track uncovered members is a
possible future change; it is not in scope now.
- A missing case is a runtime `Error`, not a compile error. A caller wants
either an `.otherwise(...)` (which never throws) or to prove coverage
themselves. Tracking uncovered members is a possible future change.
## `P.type` takes the type as a parameter
#### Decision (2026-09)
`P.type<T>(name)` requires the caller to supply `T` explicitly; it is not
inferred from the `typeof` string.
`P.type<T>(name)` requires the caller to supply `T`; it is not inferred from the
`typeof` string.
#### Why
- A runtime string cannot carry a TypeScript type. The caller pairs the two, and
- A runtime string cannot carry a TypeScript type; the caller pairs the two, and
the compiler only checks that `T` is used consistently afterwards.
#### Known issue
- The type parameter and the runtime name can disagree (`P.type<number>("string")`
compiles), because nothing ties `T` to `name`. `P.when` with a real type guard
avoids the pairing entirely, so prefer it. A future revision could map the name
to a type with a conditional type; that is not in scope now.
- `T` and the runtime `name` can disagree (`P.type<number>("string")` compiles),
because nothing ties them together. Prefer `P.when` with a real type guard. A
name-to-type conditional mapping is a possible future change.
## `P.shape` narrows only through `refine`
#### Decision (2026-09)
`P.shape<S, T>(shape, refine?)` returns `Matcher<T>`, defaulting to
`Matcher<S>` when no `refine` is given.
`P.shape<S, T>(shape, refine?)` returns `Matcher<T>`, defaulting to `Matcher<S>`
without a `refine`.
#### Why
- The shape object's values may be matchers, so the shape's literal type is not
the matched type: `S` describes the check, not the value. A `refine` type guard
is the explicit point where the value is narrowed to `T`.
- Checking keys with `in` (rather than requiring exact keys) lets a shape match a
wider object, which is how discriminated unions are handled.
- The shape's values may be matchers, so `S` describes the check, not the value;
the `refine` guard is the explicit point where the value narrows to `T`.
- Keys are checked with `in` rather than requiring an exact match, so a shape can
match a wider object — which is how discriminated unions are handled.
#### Known issue
- Without `refine`, a discriminated-union case does not narrow, so `P.shape`
on its own is easy to misuse. The README shows the `refine` form; prefer
`P.when` when a guard is already available.
- Without `refine` a discriminated-union case does not narrow, so `P.shape`
alone is easy to misuse. Prefer `P.when` when a guard is already available.
## `P.any` exists for non-guard predicates
#### Decision (2026-09)
`P.any<T>(predicate)` accepts a boolean predicate and a declared `T`.
`P.any<T>(predicate)` takes a boolean predicate and a declared `T`.
#### Why
- Some checks are cheap to write as a boolean but awkward as a type guard (for
example an `every` over an array). `P.any` is the escape hatch for those.
- Some checks are cheap as a boolean but awkward as a type guard (for example an
`every` over an array); `P.any` is the escape hatch.
#### Known issue
- Like `P.type`, the declared `T` is not proven by the predicate. Prefer
- As with `P.type`, the declared `T` is not proven by the predicate. Prefer
`P.when` whenever the check can be written as a guard.
+53 -58
View File
@@ -1,31 +1,28 @@
# Publishing
Publishing is CI-only by policy. Local `npm publish` is not supported. The
maintainer triggers releases from `main`; the mechanics live in
Publishing is CI-only: local `npm publish` is not supported, and the maintainer
triggers releases from `main`. The mechanics are in
[scripts/release.sh](../scripts/release.sh) and
[scripts/release-notes.sh](../scripts/release-notes.sh), and the job graph lives
in [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml).
[scripts/release-notes.sh](../scripts/release-notes.sh); the job graph is
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml).
## Release steps
1. All intended changes are merged to `main` and passing CI.
2. The maintainer runs `npm run create:release`. VS Code opens
[CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because
pubv refuses a dirty tree, 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 fires on both pushes: the `publish` job runs on the tag (`build` +
publish-tier checks + release page + `npm publish`), while the branch run's
`release-gate` job recognizes the release commit and skips
`build`/`maintain` — the tag verifies the identical SHA, so no work is
duplicated. 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; it runs _before_ `npm publish` so a broken
page fails CI without consuming a version, and `npm publish` stays the last
step.
pubv refuses a dirty tree, the edit is committed first (then folded into the
release commit), and pubv suggests a version from those notes to confirm or
edit.
3. `scripts/release.sh` creates one release commit (graduated changelog +
`package.json` bump, amended together), tags it, and pushes.
4. CI fires on both pushes. `publish` runs on the tag (build + publish checks +
release page + `npm publish`), while `release-gate` recognizes the release
commit and skips `build`/`maintain`: the tag verifies the identical SHA, so no
work is duplicated. The publish checks pass before the artifact is published,
and the release page is created from the matching Keep-a-Changelog section
_before_ `npm publish`, so a broken page fails CI without consuming a version
and `npm publish` stays the last step.
## CI-only publishing
@@ -39,8 +36,8 @@ Releases are cut by CI from `main`; there is no local `npm publish` and no
- The tag is the artifact marker: CI verifies the exact commit it points at, so
a local publish could ship something the tag does not describe.
- `publish:publint` / `publish:attw` validate the _publishable artifact_, which
needs a fresh build; they are not source-correctness checks, so they do not
belong in `check`.
needs a fresh build; they are not source-correctness checks and do not belong
in `check`.
## The release commit is assembled from two tools
@@ -53,32 +50,31 @@ release commit.
#### Why
- 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. Splitting by strength: `pubv` (tiny, changelog-driven)
owns preflight, the interactive major/minor/patch heuristic, and graduating and
committing `CHANGELOG.md` (no tag, no push); `npm version` syncs
`package.json` + the lockfile; `--amend` folds them into pubv's single commit;
the tag is created _after_ the amend so it is never orphaned.
- 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. The staging commit that makes pubv
accept the tree is folded back into the single release commit.
`## [x.y.z] - DATE` graduation, and a tag on the exact commit that gets
published — and no single tool did both the graduation and the `package.json`
bump.
- Split by strength: `pubv` (tiny, changelog-driven) owns preflight, the
interactive major/minor/patch heuristic, and graduating and committing
`CHANGELOG.md` (no tag, no push); `npm version` syncs `package.json` + the
lockfile; `--amend` folds them into one commit; the tag is created _after_ the
amend so it is never orphaned.
- The notes are finalized _before_ pubv because its bump heuristic reads the
`[Unreleased]` body — editing afterwards would inform the changelog only, not
the version. The staging commit that satisfies pubv's clean-tree check is
folded back into the single release commit.
#### Rejected
- The conventional-commits family: the history is gitmoji, not Conventional, and
we want hand-written notes (see
the notes are hand-written (see
[workflow.md § Commit messages](./workflow.md#commit-messages)).
- `changesets` / `rtk`: config plus a heavier version/publish flow that fights
the CI-only publish.
- `knope` / `kacl` / `bestikk`: changelog-only — they don't bump `package.json`
— and they carry 5-year / 2-year / brand-new maintenance.
- `pubv` alone: verified it never writes `package.json`.
- `versions` (silverwind): great Gitea support, but pairing it with a hand-rolled
promote became a ~180-line script to maintain, which is exactly what this
~30-line version replaces.
- `changesets` / `rtk`: config plus a heavier flow that fights the CI-only
publish.
- `knope` / `kacl` / `bestikk`: changelog-only (no `package.json` bump) and
5-year / 2-year / brand-new maintenance.
- `pubv` alone: it never writes `package.json`.
- `versions` (silverwind): good Gitea support, but pairing it with a hand-rolled
promote became a ~180-line script, which this ~30-line version replaces.
## The version has one source of truth
@@ -86,38 +82,37 @@ release commit.
The version is derived from the graduated `## [x.y.z]` heading in
[CHANGELOG.md](../CHANGELOG.md) and written to `package.json` +
`package-lock.json` by `npm version`. The README carries no version line and
does not link `package.json`.
`package-lock.json` by `npm version`. The README has no version line and does
not link `package.json`.
#### Why
- `release.sh` reads the version from the changelog, so the changelog is the
input and `package.json` is the derived copy — one direction, no drift.
input and `package.json` the derived copy — one direction, no drift.
- npm and Gitea render the version from package metadata, so a README copy would
be a third place to update for no reader benefit, and a link would only move
the lookup without removing the copy.
the lookup, not remove the copy.
#### Rejected
- A `## Version` line in the README: it would make `release.sh` responsible for
a third file.
- A `## Version` line in the README: it makes `release.sh` responsible for a
third file.
- Linking `package.json` from the README: it invites a hand-maintained duplicate
that the link does not keep in sync.
the link does not keep in sync.
## Release notes are extracted from the changelog
#### Decision (2026-09)
`scripts/release-notes.sh <tag>` prints the Keep-a-Changelog section for the tag
and exits non-zero when the section is missing.
and exits non-zero when it is missing.
#### Why
- CI reuses the release body from the same file that drove the version, so the
page and the changelog cannot disagree.
- Failing on a missing section means a release can never publish with an empty
body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match the
`## [1.2.3]` heading.
- Failing on a missing section means a release can never publish an empty body.
A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`.
## Token gates
@@ -129,11 +124,11 @@ The Gitea release page uses the run's automatic token (`github.token`).
#### Why
- The automatic token only needs `contents: write`, so no secret gate is needed
for the release page.
- `secrets` is not an allowed context in a step `if`, so `NPM_TOKEN` must be
lifted into job-level `env`; an unset secret then skips the publish instead of
attempting an unauthenticated one.
- The automatic token needs only `contents: write`, so the release page needs no
secret gate.
- `secrets` is not allowed in a step `if`, so `NPM_TOKEN` must be lifted into
job-level `env`; an unset secret then skips the publish instead of attempting
an unauthenticated one.
- A tag is all-or-nothing: without the `always()` guard a skipped or failed npm
half would leave the job silently green. The guard turns it red.
+13 -15
View File
@@ -1,8 +1,7 @@
# Testing
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. The contributor-facing commands are in
For this library the types _are_ the feature, so a runtime-only test loop would
verify the wrong thing. The commands are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped
the way it is.
@@ -10,17 +9,17 @@ the way it is.
The rules — the loop and the pairing rule — are in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
What follows is why the loop is type-driven and what was rejected.
What follows is why and what was rejected.
#### Decision (2026-09)
Test-driven development is _type_-driven here: the compile-time expectation is
written before the runtime assertion, and both before the implementation.
The compile-time expectation is written before the runtime assertion, and both
before the implementation.
#### Why
- A runtime-only test can pass while the type is wrong, and a type-level library
would then ship a broken feature that its tests bless.
- A runtime-only test can pass while the type is wrong, so a type-level library
would ship a broken feature its tests bless.
- The type error is a more precise spec than a failing assertion, because it
states the exact expected type before the logic exists.
@@ -31,17 +30,16 @@ written before the runtime assertion, and both before the implementation.
- Testing the type only: it would not catch handler wiring, `exhaustive()`
throwing, or the `otherwise` fallback (see `src/index.test.ts`).
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
listed in
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step. The runner relies on the `.ts` import-extension convention (see
build step, and the runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise,
so `src/index.test.ts` carries a file-level `oxlint-disable
typescript/no-floating-promises` with an explanatory comment. This is a known
false positive, not a rule we want off project-wide (see
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so
`src/index.test.ts` carries a file-level `oxlint-disable
typescript/no-floating-promises` with an explanatory comment. It is a known
false positive, not a rule worth disabling project-wide (see
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+83 -92
View File
@@ -1,33 +1,32 @@
# Tooling
The choice and configuration of every tool below is the result of a deliberate
trade-off, not a default. This file is the record of those trade-offs. The
commands a contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md); the
tool versions are in [package.json](../package.json).
Every tool below was chosen and configured deliberately. The commands a
contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md) and the versions
in [package.json](../package.json).
## Tool inventory
- **TypeScript 7** — type checker and build (`tsc`).
- **node --test** + `--strip-types` — test runner.
- **c8** — 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`.
- **c8** — coverage for `test:ci`.
- **oxlint** — Rust linter, type-aware via **oxlint-tsgolint** (typescript-go).
- **oxfmt** — Rust formatter (Prettier-compatible) for JS/TS, JSON/JSONC, YAML,
Markdown, MDX, and more; its `package.json` key sorting replaces
`sort-package-json`.
- **cspell** — spell checking.
- **knip** — 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.
- **knip** — unused dependencies, exports, and files.
- **check-outdated** — dependencies behind the registry; exits non-zero when any
is outdated.
- **publint** — validates `package.json` for ESM publishing.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` against module-resolution
scenarios.
- **lefthook** — git hooks.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents
(project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents
(project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via
`tsc --lsp --stdio`.
When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
When each runs is in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
## TypeScript and build
@@ -36,44 +35,43 @@ When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md
#### Decision (2026-09)
`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.
`tsconfig.build.json` adds the emit-only options (`declaration`, `sourceMap`,
`inlineSources`, `outDir`, `target: es2024`,
`rewriteRelativeImportExtensions: true`) and excludes test files.
#### Why
- It lets the editor and CI type-check from one config while the build emits
from the other, so a test file cannot leak into `dist/`.
- The editor and CI type-check from one config while the build emits from the
other, so a test file cannot leak into `dist/`.
- `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so
debuggers can map into `src/` without it being shipped.
- `declarationMap` is intentionally off: a `.d.ts.map` cannot embed source and
would dangle.
debuggers map into `src/` without it being shipped.
- `declarationMap` stays off: a `.d.ts.map` cannot embed source and would
dangle.
### The build starts from an empty `dist/`
#### Decision (2026-09)
`npm run build` first runs a `prebuild` hook that empties `dist/`.
`npm run build` runs a `prebuild` hook that empties `dist/`.
#### Why
- `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.
- `tsc` does not prune orphaned emit output — dropping `declarationMap` left
stale `*.d.ts.map` files — so reproducibility needs an empty `dist/`.
- `prebuild` removes only `dist`; the manual `clean` resets `dist` + `coverage`,
so a local coverage report survives a build.
### Source imports use `.ts` extensions
#### Decision (2026-09)
Source imports use `.ts` extensions, never `.js`.
Source imports use `.ts`, never `.js`.
#### Why
- `node --strip-types` only resolves the `.ts` form at test time.
- `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them
to `.js` in the emitted JavaScript.
- `node --strip-types` resolves the `.ts` form at test time.
- `rewriteRelativeImportExtensions` rewrites them to `.js` in the emitted
JavaScript.
- The emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves
(see [README § Requirements](../README.md#requirements)), so no
post-processing step is needed.
@@ -89,43 +87,41 @@ Source imports use `.ts` extensions, never `.js`.
#### Decision (2026-09)
Type-aware oxlint is enabled declaratively via `options.typeAware: true` in
`.oxlintrc.json` (powered by `oxlint-tsgolint`).
Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json`
(powered by `oxlint-tsgolint`).
#### Why
- The script commands stay clean — no CLI flag.
- Type-aware mode is a property of the config, not the invocation, so it cannot
be forgotten on one call site.
- A config property cannot be forgotten on one call site.
#### Rejected
- Passing a CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the
mode in two places and invites them to drift.
- A CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the mode in
two places and invites them to drift.
### `oxlint-disable` directives live next to the code
#### Decision (2026-09)
Source-level `oxlint-disable` directives are used for known type-aware false
positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), rather
than rules being disabled in `.oxlintrc.json`.
Known type-aware false positives are silenced with source-level `oxlint-disable`
directives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), not with
rules disabled in `.oxlintrc.json`.
#### Why
- The disable lives next to the code it silences, so the trade-off is visible to
anyone reading the source.
- The disable sits next to the code it silences, visible to anyone reading the
source.
#### Rejected
- Silencing a rule project-wide in `.oxlintrc.json`: it hides the suppression
from the reader of the affected code.
- A project-wide disable in `.oxlintrc.json`: it hides the suppression from the
reader of the affected code.
#### Known issue
- A source-level disable is a _human_ last-resort convention. AI coding agents
must not add one; they fix the type at its root instead (see
[AGENTS.md § Never do](../AGENTS.md#never-do)).
- A source-level disable is a _human_ last resort. AI agents must not add one;
they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)).
### `check:tsc` runs first
@@ -135,24 +131,23 @@ than rules being disabled in `.oxlintrc.json`.
#### Why
- A type error short-circuits the rest, which is faster feedback than letting
oxlint/oxfmt run and then failing on `tsc` at the end.
- A type error short-circuits the rest, which is faster than running
oxlint/oxfmt and failing on `tsc` at the end.
### `.editorconfig` is a fallback, not a gate
#### Decision (2026-09)
`.editorconfig` exists for editor compatibility. Where both apply,
`.editorconfig` exists for editor compatibility; where both apply,
`.oxfmtrc.json` is authoritative.
#### Why
- `.editorconfig` 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).
- oxfmt is the formatter; the overlapping `.editorconfig` keys only keep
non-oxfmt editors close to the formatted result, so they cannot disagree with
the checker.
- `.editorconfig` covers the files oxfmt does not format: shell scripts,
dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG`
buffer.
- oxfmt is the formatter; the overlapping keys only keep non-oxfmt editors close
to the formatted result, so they cannot disagree with the checker.
## Static analysis and packaging
@@ -160,14 +155,13 @@ than rules being disabled in `.oxlintrc.json`.
#### Decision (2026-09)
`knip --include dependencies,exports,files` intentionally omits the `types`
category.
`knip --include dependencies,exports,files` omits the `types` category.
#### Why
- The `types` category 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.
- `types` produces systematic false positives for libraries whose exported types
are part of the public API.
- The narrower scope keeps the signal high without config-file boilerplate.
### `attw` targets ESM-only
@@ -177,9 +171,8 @@ category.
#### Why
- It 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.
- The package is intentionally ESM-only (no CommonJS shim), so CJS resolution
scenarios are out of scope by design, not a bug.
### `tslib` and `type-fest` are deliberately not used
@@ -189,10 +182,10 @@ Neither `tslib` nor `type-fest` is a dependency.
#### Why
- `tslib` is a runtime helper for old ES3/ES5 targets; the project targets
- `tslib` is a runtime helper for old ES3/ES5 targets; this project targets
ES2024.
- `type-fest` was never imported.
- `knip` flagged both, which is the same signal that keeps the list honest.
- `knip` flagged both, the same signal that keeps the list honest.
## Git hooks and script wiring
@@ -200,15 +193,14 @@ Neither `tslib` nor `type-fest` is a dependency.
#### Decision (2026-09)
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.
The pre-commit hook sets `LEFTHOOK_FILES` to the staged-files list, and the
affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole
project.
#### Why
- It keeps `package.json#scripts` as the single source of truth for the
underlying commands — `lefthook.yml` only describes _what to run on which
files_.
- It keeps `package.json#scripts` the single source of truth; `lefthook.yml`
only says what to run on which files.
- The same script works by hand (whole project) and staged (scoped), so there is
no second command to maintain.
@@ -216,13 +208,13 @@ whole project when invoked manually.
### VSCode integration
- Recommended extensions: see
- Recommended extensions are in
[.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript
native-preview, EditorConfig, todo-tasks).
- TypeScript 7 is used via the `typescriptteam.native-preview` extension.
- TypeScript 7 runs via the `typescriptteam.native-preview` extension.
- The oxc extension provides oxlint squiggles and oxfmt format-on-save;
`.vscode/settings.json` pins it per language so a user's local `[language]`
formatter settings cannot override the project's choice.
`.vscode/settings.json` pins it per language so a local `[language]` formatter
setting cannot override the project's choice.
### `@spences10/pi-lsp` is pinned and read-only
@@ -232,16 +224,15 @@ whole project when invoked manually.
#### Why
- The package inspects `node_modules/typescript`, sees major >= 7 with no
- It inspects `node_modules/typescript`, sees major >= 7 with no
`lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the
repo's own `tsc --lsp --stdio` binary — no `typescript-language-server`
dependency is required.
repo's own `tsc --lsp --stdio` — no `typescript-language-server` dependency is
needed.
- Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server
--stdio` and are TS6-only.
- The tool is _intermediate_ agent feedback (hover, references, definition,
symbols, diagnostics). It has no rename / code-action / apply-edit surface,
and is 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.
- It is _intermediate_ agent feedback (hover, references, definition, symbols,
diagnostics), with no rename / code-action / apply-edit surface, and is never a
gate — `npm run check` / `verify` are.
- `.pi/settings.json` is the committed declaration; `.pi/npm/` is a gitignored
install cache that pi recreates on a trusted startup (running `npm install`
for any missing project package), so it is deliberately not tracked.
+77 -98
View File
@@ -1,164 +1,143 @@
# Workflow
How work moves through the repository: the branching model, the `npm run`
script taxonomy, the feedback tiers, and commit messages. The contributor-facing
steps are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they
are shaped the way they are.
How work moves through the repository. The rules are in
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they are shaped the
way they are.
## Branching model
The contributor-facing steps are in
[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Two
bits of context behind the model: collaborative review through the Gitea UI is
not in place, so Gitea is the lab; and the project will be promoted to GitHub
once it is tested and ready for production. What follows is why the front doors
exist and what was rejected.
The model is GitHub Flow (single-developer); the steps are in
[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Context
behind it: Gitea has no collaborative review UI in use, so it is the lab, and
the project moves to GitHub once it is tested and ready.
#### Decision (2026-09)
Work is opened and closed by `create:branch` / `create:finish` rather than by
prose plus hand-written `git` commands.
Work is opened and closed by `create:branch` / `create:finish`, not by prose
plus hand-written `git`.
#### Why
- The branching model's preconditions were prose. Prose rots silently — a rule
nobody checks is a suggestion. A script asserts, then acts, and the branch or
merge only happens if the assertions passed.
- The type-driven loop only produces trustworthy results if the baseline was
green before the first edit. Cheap checks run first and `npm run test` last, so
the expensive gate is not paid on a tree that was never eligible.
- `create:finish` owns the merge-side preconditions and the post-merge
`npm run verify`, so a merge cannot land unverified. The push stays with
`create:release` so the merge remains local and reviewable until then.
- Every check failure is non-mutating, except the baseline test which runs on
`main` after switching there — a red `main` restores the branch you started on
rather than stranding you on it. A merge conflict aborts and returns to the
feature branch, so a failed finish never leaves a half-merged `main`.
- The preconditions were prose, and prose rots: a rule nobody checks is a
suggestion. A script asserts, then acts, so the branch or merge only exists if
the assertions passed.
- Type-driven work is only trustworthy if the baseline was green before the
first edit. Cheap checks run first and `npm run test` last, so the expensive
gate is not paid on an ineligible tree.
- The merge half owns the post-merge `npm run verify`, so a merge cannot land
unverified. The push stays with `create:release` so the merge is reviewed
locally first.
- Every failure is non-mutating except the baseline test, which runs on `main`
after switching there: a red `main` restores the branch you started on, and a
merge conflict aborts back to the feature branch rather than stranding a
half-merged `main`.
#### Rejected
- Hand-written `git switch -c` / `git merge`: same rules, no enforcement.
- Reusing `pubv`'s preflight for `create:branch`: it is release-shaped and
third-party, and would make starting a branch pay for a build and pack it has
no use for.
- Leaving the merge as reviewer judgment: that judgment still exists, but it now
happens when the maintainer reviews the handover _before_ invoking
`create:finish`, rather than being encoded in a command that anyone can run
from a dirty tree.
- `--no-ff` rather than a fast-forward: it keeps each unit of work visible in
`git log`.
- Reusing `pubv`'s preflight for `create:branch`: release-shaped, third-party,
and it would pay for a build and pack a new branch has no use for.
- Leaving the merge to reviewer judgment: that judgment moved earlier, to the
handover review before `create:finish`, rather than living in a command anyone
can run from a dirty tree.
- Fast-forward instead of `--no-ff`: `--no-ff` keeps each unit of work visible
in `git log`.
#### Known issue
- `create:finish` deliberately does not push, so `main` is ahead of
`origin/main` between a merge and the next push or release. `create:branch`
requires `main` to match its upstream and will refuse until it is pushed. Push
`main` before starting the next branch.
- `create:finish` does not push, so `main` is ahead of `origin/main` between a
merge and the next push. `create:branch` requires `main` to match its upstream
and refuses until it is pushed; push `main` before starting the next branch.
## Script prefix convention
The prefix taxonomy is the rule, and it lives in
[CONTRIBUTING.md § Script prefix convention](../CONTRIBUTING.md#script-prefix-convention).
What follows is the design rationale and the `create:` decision.
One part of the taxonomy is not a prefix rule: 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.
The design behind it: bare scripts are the tier entry points — a single tool
(`build`, `clean`) or an aggregator of a `prefix:*` family (`check`, `fix`,
`test`, `watch`, `maintain`, `setup`) — while `verify` composes `check` +
`test:unit` into the whole-project gate (it uses `test:unit`, not `test`,
because `check` already runs `check:tsc`).
#### Decision (2026-09)
`create:` is the prefix for workflow front doors, and there is no bare `create`
`create:` is the prefix for workflow front doors, with no bare `create`
aggregator.
#### Why
- Both members really do create something: a branch, a release.
- The prefix was added to both lists in [CONTRIBUTING.md](../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 previously
carried (since retired into `setup:`).
- Both members create something real: a branch, a release.
- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its
first members, so it could not go invisible the way the retired `use:` prefix
did.
- `publish:*` already set the precedent for a prefix without an aggregator.
#### Rejected
- `run:` / `perform:` — 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
- `run:` / `perform:`: they mean only "do the named thing", so every script fits
and the taxonomy collapses.
- `git:`: names the tool, not the lifecycle moment, and implies passthrough
aliases.
- `start:` — describes the branch half, not the release.
- `cut:` — idiomatic 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.
- 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.
- `start:`: describes the branch half, not the release.
- `cut:`: idiomatic but needs VCS slang to decode.
- `flow:`: overloaded in a type-level matching library.
- The existing families: `check:*` is read-only (CI would run a state-mutating
command), `fix:*` reviews as a diff not a branch, `maintain:*` is advisory and
never a gate.
## Feedback tiers
The tier table and the rules for invoking it are in
The table and invocation rules are in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this
section explains why the split is where it is.
section explains the split.
#### Decision (2026-09)
Split fast, offline, staged-file checks into pre-commit; whole-project test runs
into pre-push and `verify`; and slow or network-bound scans into `maintain`.
Fast, offline, staged-file checks sit in pre-commit; whole-project test runs in
pre-push and `verify`; slow or network-bound scans under `maintain`.
#### Why
- **`watch:*` 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 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.
- `watch:*` runs until killed, in its own pane, so it is the earliest tier,
firing on save before staging or commit.
- `check:tsc` / `check:oxlint` / `check:oxfmt` / `check:cspell` are fast
(~0.2–0.5s each), offline, and scope to staged files via `LEFTHOOK_FILES`, so
pre-commit gives instant feedback on what you typed.
- `test` (and its `tsc`) runs the whole suite over the whole project, and the
staged-file convention does not apply to the test runner, so it belongs in
pre-push, after the commits exist but before the push leaves the machine.
#### Rejected
- Running `maintain:*` in `check` or pre-commit: advisory, whole-project and
network-bound scans are not correctness gates and would make the fast tier
slow.
- Treating a green pre-commit as the definition of done: it only sees staged
files, which is why `npm run verify` exists as the one-shot whole-project gate.
- A separate `git push` hook for `verify`: it would duplicate the pre-push test
tier; `verify` is run by hand because the push is where the whole project is
already checked.
- `maintain:*` in `check` or pre-commit: advisory, whole-project and
network-bound scans are not correctness gates and would slow the fast tier.
- Treating a green pre-commit as the definition of done: it sees only staged
files, hence `npm run verify`.
- A separate `git push` hook for `verify`: the pre-push test tier already covers
it.
## Commit messages
The convention is in
[CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages).
Examples from history: `:sparkles: Add watch tier with watch:test child`,
Examples: `:sparkles: Add watch tier with watch:test child`,
`:recycle: Move type-aware config to .oxlintrc.json; use source-level disable
directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The
body explains _what and why_, not _how_; link issues with `Resolves #...`.
body explains what and why, not how; link issues with `Resolves #...`.
#### Decision (2026-09)
Gitmoji subjects with imperative mood, wrapped 50/72, rather than Conventional
Commits.
Gitmoji subjects, imperative mood, wrapped 50/72, not Conventional Commits.
#### Why
- The history is gitmoji, not Conventional, and it predates any commit-lint
tooling; switching would rewrite the convention for no gain.
- The body carries reasoning, which is what a reviewer needs; the subject is a
signpost, not a semantic key.
- The history is gitmoji and predates any commit-lint tooling; switching would
rewrite the convention for no gain.
- The body carries the reasoning a reviewer needs; the subject is a signpost,
not a semantic key.
#### Rejected
- Conventional Commits: the release flow uses hand-written Keep-a-Changelog
notes, not generated ones, so the prefix carries no automation value here (see
notes, not generated ones, so the prefix has no automation value here (see
[publishing.md](./publishing.md)).