📝 Add development/ docs for decisions and known issues

Category files under development/ replace the decision prose that was
scattered through README.md and CONTRIBUTING.md. Each decision is a block with
Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records
the public API contract and its limitations. AGENTS.md and backlog.tasks now
point at the new home.
This commit is contained in:
tmu committed 2026-09-15 13:26:59 +00:00
1 parent 97dfe9e7b4
commit dc7ce22fc5
9 files changed
+1049 -5

No files matched your search

+2 -1
View File
@@ -64,5 +64,6 @@ In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CO
- [CONTRIBUTING.md § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong. - [CONTRIBUTING.md § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong.
- [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72. - [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72.
- [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI). - [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI).
- [README.md § Tooling decisions](./README.md#tooling-decisions) — the rationale behind each tool choice; read before changing tooling. - [development/](./development/README.md) — the decisions, rejected alternatives and known issues behind the rules; the “why” that CONTRIBUTING.md links to. Read the relevant file before changing an area.
- [development/tooling.md](./development/tooling.md) — the rationale behind each tool choice; read before changing tooling.
- [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project). - [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project).
+10 -4
View File
@@ -27,12 +27,18 @@ Documentation:
☐ Clean up CONTRIBUTING.md and README.md, create docs ☐ Clean up CONTRIBUTING.md and README.md, create docs
☐ Review existing documentation for accuracy and completeness ☐ Review existing documentation for accuracy and completeness
☐ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors ☐ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors
☐ A lot of decisions are documented in the current README.md and CONTRIBUTING.md, that are not part of a main entry for users, how to use the library nor part of a main entry for contributors, ☐ Move the decisions, shortcomings and known issues out of README.md and CONTRIBUTING.md
☐ evaluate a new structure for the docs, and move the relevant information to the new docs ☐ Decided: category files under development/ (one per area), not ADRs. Each decision is a block with #### Decision (YYYY-MM) / #### Why / #### Rejected / #### Known issue; rationale in development/README.md
☐ have a look at other well known repositories for inspiration on how to structure the docs ☐ have a look at other well known repositories for inspiration on how to structure the docs
☐ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now ☐ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now
☐ More important is to have the documentation of our decisions, shortcomings and known issues, not sure where to put this. at work we have ADRs, maybe that? Please suggest something after having looked at other well known repos
☐ make sure to preserve that information in the new docs ☐ make sure to preserve that information in the new docs
☐ development/README.md - index and decision-block convention
☐ development/workflow.md - branching, script prefixes, feedback tiers, commits
☐ development/tooling.md - toolchain decisions and editor setup
☐ development/testing.md - type-driven testing
☐ development/ci.md - pipeline, runner image, coverage serving
☐ development/publishing.md - release and npm publishing
☐ development/library.md - public API design and its limitations
☐ README.md ☐ README.md
☐ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license ☐ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license
(example: https://metacpan.org/pod/Scalar::Util) (example: https://metacpan.org/pod/Scalar::Util)
@@ -75,7 +81,7 @@ Maintenance:
✔ Log tool-cache state from the job container to find it (temporary, removed once understood) @done ✔ Log tool-cache state from the job container to find it (temporary, removed once understood) @done
✔ Guard the invariant in CI (`Assert the baked tool cache is present`) @done ✔ Guard the invariant in CI (`Assert the baked tool cache is present`) @done
✘ Enable force-pull for the runner so a changed act-ci image is never missed @low @cancelled ✘ Enable force-pull for the runner so a changed act-ci image is never missed @low @cancelled
→ decided against: it is acceptable to miss a runner-side image change, and the image is only rebuilt on a Node bump, which changes the tag anyway. A Dockerfile-only change re-pushed under an unchanged tag is a known issue with a manual `docker rmi` workaround (see CONTRIBUTING § CI runner image). → decided against: it is acceptable to miss a runner-side image change, and the image is only rebuilt on a Node bump, which changes the tag anyway. A Dockerfile-only change re-pushed under an unchanged tag is a known issue with a manual `docker rmi` workaround (see development/ci.md).
✔ Improve CI publish @done ✔ Improve CI publish @done
✔ Check whether publish job is only run on tags, if not, guard it @done ✔ Check whether publish job is only run on tags, if not, guard it @done
✔ Gate only single steps @done ✔ Gate only single steps @done
+85
View File
@@ -0,0 +1,85 @@
# 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.
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.
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.
## Layout
One file per category:
| File | Covers |
| -------------------------------- | ----------------------------------------------------------------------- |
| [library.md](./library.md) | Public API design, the type-level contract, and its limitations |
| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages |
| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup |
| [testing.md](./testing.md) | Test strategy and type-driven development |
| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving |
| [publishing.md](./publishing.md) | Release and npm publishing |
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.
## Decision blocks
Every non-obvious choice is recorded as a block inside the relevant category
file, using this shape:
```md
## Runner image
#### Decision (2026-09)
Bake Node into the CI job image at the setup-node tool-cache layout instead
of downloading per job.
#### Why
- ...
#### Rejected
- Gitea Pages / per-job download
- force-pull
#### Known issue
- a Dockerfile-only change re-pushed under an unchanged tag is invisible to the runner
- recover with `docker rmi <image>`
```
- The date is the month the decision was made, not 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.
## Adding to these docs
1. Pick the category file for the area you are changing: `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.
+146
View File
@@ -0,0 +1,146 @@
# 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.
## 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.
- **`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.
## 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.
#### 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.
#### 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
(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.
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>`.
3. Repoint the three `container.image` tags in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to the same 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:
### The `x64.complete` marker
#### Decision (2026-09)
Bake a `<version>/<arch>.complete` marker next to the Node directory.
#### Why
- `actions/tool-cache` accepts a cached tool only when
`<version>/<arch>.complete` exists 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).
### Tag freshness, with force-pull deliberately off
#### Decision (2026-09)
Leave `act_runner`'s `force_pull` disabled.
#### Why
- The tag encodes only the Node version, and the image is rebuilt only when that
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.
#### Rejected
- Enabling `force_pull`: it is acceptable to miss a runner-side image change,
and a `Dockerfile`-only change is not worth a per-job pull.
#### Known issue
- A `Dockerfile`-only change (like the marker above) re-pushed under an
unchanged tag is invisible to the runner — 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.
## Coverage serving
#### Decision (2026-09)
Serve CI coverage from a shared directory on the runner, with no deploy step in
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.
- Coverage is written to a shared volume keyed by project and tag (for example
`/docs/tiny-pattern-ts/<tag>/`).
#### Rejected
- Gitea Pages and Codecov: neither was confirmed available or wanted.
#### Known issue
- Coverage is currently served for tag pushes only. Serving it for non-tag
pushes (for example `main/coverage`) is 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.
#### Decision (2026-09)
Precompress text assets into sidecars rather than compressing on each 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.
+116
View File
@@ -0,0 +1,116 @@
# 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.
## 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>`.
#### 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.
## The builder is immutable
#### Decision (2026-09)
`match(value)` returns a builder whose `.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.
## `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.
#### 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.
#### 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.
## `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.
#### Why
- 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.
## `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.
#### 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.
#### 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.
## `P.any` exists for non-guard predicates
#### Decision (2026-09)
`P.any<T>(predicate)` accepts 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.
#### Known issue
- Like `P.type`, the declared `T` is not proven by the predicate. Prefer
`P.when` whenever the check can be written as a guard.
+140
View File
@@ -0,0 +1,140 @@
# Publishing
Publishing is CI-only by policy. Local `npm publish` is not supported. The
maintainer triggers releases from `main`; the mechanics live 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).
## 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.
## CI-only publishing
#### Decision (2026-09)
Releases are cut by CI from `main`; there is no local `npm publish` and no
`publish:*` script in the `check` chain.
#### Why
- The tag is the artifact marker: CI verifies the exact commit it points at, so
a local publish could ship something the tag does not describe.
- `publish:publint` / `publish:attw` validate the _publishable artifact_, which
needs a fresh build; they are not source-correctness checks, so they do not
belong in `check`.
## The release commit is assembled from two tools
#### Decision (2026-09)
`create:release` uses `pubv` for the changelog graduation and bump heuristic,
then `npm version` for the `package.json` + lockfile bump, amended into a single
release commit.
#### Why
- We want hand-written Keep-a-Changelog notes, an `[Unreleased]` ->
`## [x.y.z] - DATE` graduation, and a tag 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.
#### Rejected
- The conventional-commits family: the history is gitmoji, not Conventional, and
we want hand-written notes (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.
## The version has one source of truth
#### Decision (2026-09)
The version is derived from the graduated `## [x.y.z]` heading in
[CHANGELOG.md](../CHANGELOG.md) and written to `package.json` +
`package-lock.json` by `npm version`. The README carries 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.
- 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.
#### Rejected
- A `## Version` line in the README: it would make `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.
## 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.
#### 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.
## Token gates
#### Decision (2026-09)
The Gitea release page uses the run's automatic token (`github.token`).
`npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env`. A final
`always()` step fails the job unless both halves reported `success`.
#### Why
- The automatic token 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.
- A tag is all-or-nothing: without the `always()` guard a skipped or failed npm
half would leave the job silently green. The guard turns it red.
Set `NPM_TOKEN` (npm publish rights) under Settings -> Actions -> Secrets.
+76
View File
@@ -0,0 +1,76 @@
# 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
[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped
the way it is.
## Type-driven development
New behavior follows **type-driven development** (in Edwin Brady's sense):
_treat the type as the plan for a program, and use the compiler and type checker
as your assistant, guiding you to a complete program that satisfies the type_
([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the
`expectTypeOf` assertion, written first. The loop is **type → red → green →
refactor**:
1. **Type** — write the compile-time expectation first
(`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on
the _type_. The type error is the spec you want to hit before the runtime
logic exists.
2. **Red** — add the matching runtime assertion (`assert.*`) so
`npm run test:unit` now fails on behavior.
3. **Green** — implement in `src/*.ts` until both the type check and the test
pass.
4. **Refactor** — with the type system and the tests as the safety net, then
`npm run verify` as the definition-of-done gate.
This is why every test in the suite pairs an `expectTypeOf(...)` with an
`assert.*` — keep them together.
#### 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.
#### 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.
- The type error is a more precise spec than a failing assertion, because it
states the exact expected type before the logic exists.
#### Rejected
- Runtime-first (classic red/green): it verifies the value, not the contract,
and the contract is the product.
- Testing the type only: it would not catch handler wiring, `exhaustive()`
throwing, or the `otherwise` fallback (see `src/index.test.ts`).
## Enforcement
Type-first is also enforced structurally: `npm test` runs `check:tsc` before the
test runner, so a wrong type can never be papered over by a passing assertion.
Per [AGENTS.md § Never do](../AGENTS.md#never-do), reach green honestly — fix the
types so both the type check and the runtime assertion pass, never suppress the
ones you can't make pass.
## Test tiers
- `npm run test:unit` — the test runner alone, for the fast local loop.
- `npm test` — `check:tsc` + the unit suite; this is what pre-push and the
baseline check run.
- `npm run test:ci` — adds c8 coverage; used by CI. `c8` uses V8 coverage, so
the `--strip-types` source is instrumented without a build step.
The runner is `node --test --strip-types "src/**/*.test.ts"`. It relies on the
`.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)).
#### Known issue
- 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
[tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)).
+247
View File
@@ -0,0 +1,247 @@
# 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).
## 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`.
- **cspell** — spell checking.
- **knip** — finds unused dependencies, exports, and files.
- **check-outdated** — reports dependencies behind the registry; it exits
non-zero whenever _any_ dependency is outdated.
- **publint** — validates `package.json` for ESM publishing correctness.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against
multiple module-resolution scenarios.
- **lefthook** — git hooks.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents
(project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via
`tsc --lsp --stdio`.
When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers).
## TypeScript and build
### One type-check config, one emit config
#### Decision (2026-09)
`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`.
`tsconfig.build.json` extends it to add the emit-only options (`declaration`,
`sourceMap`, `inlineSources`, `outDir`, `target: es2024`,
`rewriteRelativeImportExtensions: true`) and to exclude 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/`.
- `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.
### The build starts from an empty `dist/`
#### Decision (2026-09)
`npm run build` first 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.
### Source imports use `.ts` extensions
#### Decision (2026-09)
Source imports use `.ts` extensions, 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.
- The emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves
(see [README § Requirements](../README.md#requirements)), so no
post-processing step is needed.
#### Rejected
- "Pre-fixing" an import to `.js`: it breaks the inner `node --strip-types`
loop.
## Linting and formatting
### Type-aware oxlint is a config property
#### Decision (2026-09)
Type-aware oxlint is enabled declaratively 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.
#### Rejected
- Passing 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`.
#### Why
- The disable lives next to the code it silences, so the trade-off is 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.
#### 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)).
### `check:tsc` runs first
#### Decision (2026-09)
`check:tsc` runs first in the `npm run check` chain.
#### Why
- A type error short-circuits the rest, which is faster feedback than letting
oxlint/oxfmt run and then failing on `tsc` at the end.
### `.editorconfig` is a fallback, not a gate
#### Decision (2026-09)
`.editorconfig` exists for editor compatibility. Where both apply,
`.oxfmtrc.json` is authoritative.
#### Why
- `.editorconfig` 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.
## Static analysis and packaging
### `knip` omits the `types` category
#### Decision (2026-09)
`knip --include dependencies,exports,files` intentionally 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.
### `attw` targets ESM-only
#### Decision (2026-09)
`attw --profile esm-only` is used.
#### 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.
### `tslib` and `type-fest` are deliberately not used
#### Decision (2026-09)
Neither `tslib` nor `type-fest` is a dependency.
#### Why
- `tslib` is a runtime helper for old ES3/ES5 targets; the project targets
ES2024.
- `type-fest` was never imported.
- `knip` flagged both, which is the same signal that keeps the list honest.
## Git hooks and script wiring
### `LEFTHOOK_FILES` scopes commands to staged files
#### 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.
#### 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_.
- The same script works by hand (whole project) and staged (scoped), so there is
no second command to maintain.
## Editor and agent tooling
### VSCode integration
- Recommended extensions: see
[.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript
native-preview, EditorConfig, todo-tasks).
- TypeScript 7 is used 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.
### `@spences10/pi-lsp` is pinned and read-only
#### Decision (2026-09)
`@spences10/pi-lsp` is pinned to `0.0.46` and used read-only.
#### Why
- The package inspects `node_modules/typescript`, sees major >= 7 with no
`lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the
repo's own `tsc --lsp --stdio` binary — no `typescript-language-server`
dependency is required.
- Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server
--stdio` and are TS6-only.
- The tool is _intermediate_ agent feedback (hover, references, definition,
symbols, diagnostics). It has no rename / code-action / apply-edit surface,
and 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.
+227
View File
@@ -0,0 +1,227 @@
# 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.
## Branching model
**GitHub Flow (single-developer).** Every change — feature, fix, refactor —
branches off `main` and is merged back via a local commit. There is no pull
request workflow on Gitea yet: collaborative review through the Gitea UI is not
in place, so Gitea is the lab. When something is tested and ready for
production it will be promoted to GitHub.
- **Base branch:** `main`
- **Branch naming:** `feature/<desc>` / `fix/<desc>` / `chore/<desc>`
- **Starting work:** `npm run create:branch -- <prefix>/<desc>`. It refuses,
without changing anything, unless the working tree is clean (untracked files
included), no merge/rebase/cherry-pick is in progress, `main` matches its
upstream, and `npm run test` is green on `main` — so a later failure is always
attributable to your edits. The prefix is still _your_ call, inferred from the
task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It asserts the same
clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale
`main` (a true divergence is refused), merges the branch `--no-ff`, runs
`npm run verify`, and deletes the branch only after the merge is green. The
push is deliberately left to `create:release`, so the merge stays local and
reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is
the authoritative gate. The one exception: a push headed by a release commit
(`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because
`create:release` pushes the tag for that exact commit right after and the tag
run is the authoritative one (see `release-gate` in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml)).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a
release (see [publishing.md](./publishing.md)).
#### Decision (2026-09)
Work is opened and closed by `create:branch` / `create:finish` rather than by
prose plus hand-written `git` commands.
#### 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`.
#### 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`.
#### 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.
## Script prefix convention
Script names in `package.json` use a prefix that signals _when_ the script is
intended to run. A `<prefix>:<name>` script is implicitly aggregated by a
`<prefix>` script (if one exists) and run by the corresponding lefthook hook or
CI step. Picking the right prefix documents the script's intended lifecycle:
- `create:*` — front doors of the repo's own workflow; these mutate git state
rather than the source. `create:branch` opens a unit of work, `create:finish`
closes the branch half, `create:release` closes the release half
(maintainer-only). No bare `create` aggregator on purpose — see `publish:*`
for the precedent.
- `check:*` — read-only verification; never modifies files. Aggregated by
`npm run check`.
- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by
`npm run fix`; the diff is the review surface.
- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` +
unit tests); `test:unit` skips the typecheck for fast local iteration;
`test:ci` adds c8 coverage.
- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by
`watch`; currently a single child (`watch:test`), and a future
`watch:oxlint` / `watch:tsc` would run concurrently under that umbrella.
- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project
and/or network-bound, so never a correctness gate. Aggregated by
`npm run maintain`.
- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than
the source, so it needs a fresh build.
- `setup:*` — one-time configuration of a fresh clone; mutates the local
environment (git config, editor settings) rather than the repo source, so it
is never part of a hook or CI step. Aggregated by `npm run setup` (the
umbrella), run once after cloning.
A new script should pick the prefix that matches its lifecycle, not invent a new
one. If no existing prefix fits, that's a signal the script doesn't belong in
the standard pipeline. When it genuinely does belong, a new prefix is allowed —
but it enters both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in the same
commit as its first member, otherwise the rule "reuse an existing prefix"
silently develops an exception.
Separately, some top-level scripts are **bare** (no prefix): the entry points
that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family
(`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that
composes across tiers: `verify` — composing `check` + `test:unit` into one
whole-project correctness gate (it deliberately uses `test:unit` rather than
`test` because `check` already runs `check:tsc`, so the type checker runs
exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts
are what those tiers are made of.
#### Decision (2026-09)
`create:` is the prefix for workflow front doors, and there is 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:`).
- `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
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.
## Feedback tiers
The tools are organized into a feedback ladder. Each tier catches different
things at different costs; the rule of thumb is "earlier tiers fire more often,
faster tiers catch less, slower tiers are more thorough". The tier table itself
lives in [CONTRIBUTING.md](../CONTRIBUTING.md#feedback-tiers); this section
explains why the split is where it is.
#### 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`.
#### 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.
#### 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.
Before pushing, run `npm run verify` — the one-shot correctness gate. Run
`npm run maintain` only on a maintenance / update-deps branch.
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is
[commit-message-template](../commit-message-template); run
`npm run setup:git-commit-message` once after cloning to register it as git's
`commit.template` (or `npm run setup` to run every one-time clone step).
Examples from history: `:sparkles: Add watch tier with watch:test child`,
`:recycle: Move type-aware config to .oxlintrc.json; use source-level disable
directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The
body explains _what and why_, not _how_; link issues with `Resolves #...`.
#### Decision (2026-09)
Gitmoji subjects with imperative mood, wrapped 50/72, rather than 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.
#### 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
[publishing.md](./publishing.md)).