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