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