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