📝 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:
1 parent
97dfe9e7b4
commit
dc7ce22fc5
9 files changed
+1049
-5
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
Reference in new issue
Block a user