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