From 97dfe9e7b46d653092693a2eb87c22a301c7779c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:21:58 +0000 Subject: [PATCH 01/12] :memo: Expand the docs-cleanup task into concrete subtasks --- backlog.tasks | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/backlog.tasks b/backlog.tasks index dc8cee8..e8b4f12 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -25,9 +25,30 @@ Enhancements: Documentation: ☐ Clean up CONTRIBUTING.md and README.md, create docs -☐ Add usage examples to README.md + ☐ Review existing documentation for accuracy and completeness + ☐ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors + ☐ A lot of decisions are documented in the current README.md and CONTRIBUTING.md, that are not part of a main entry for users, how to use the library nor part of a main entry for contributors, + ☐ evaluate a new structure for the docs, and move the relevant information to the new docs + ☐ have a look at other well known repositories for inspiration on how to structure the docs + ☐ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now + ☐ More important is to have the documentation of our decisions, shortcomings and known issues, not sure where to put this. at work we have ADRs, maybe that? Please suggest something after having looked at other well known repos + ☐ make sure to preserve that information in the new docs + ☐ README.md + ☐ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license + (example: https://metacpan.org/pod/Scalar::Util) + ☐ should include a clear description of the library, its purpose, and how to use it + ☐ Add usage examples to README.md + ☐ version needs to be kept in sync with package.json in release.sh + ☐ Not every section in current README fits in the above order, so put them in another file + ☐ CONTRIBUTING.md + ☐ should include instructions for how to contribute to the project, including how to set up a development environment, run tests, and submit pull requests + ☐ should include guidelines for code style and formatting and a hint, that vscode extensions are suggested from .vscode/extensions.json + ☐ Not every section in current CONTRIBUTING.md fits in, so put them in another file + + + ☐ Create `examples/` directory with runnable snippets -☐ Add comparison section vs. other TS pattern-matching libs +☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation From dc7ce22fc5c337b79accf796f47160b71a2045b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:26:59 +0000 Subject: [PATCH 02/12] :memo: Add development/ docs for decisions and known issues Category files under development/ replace the decision prose that was scattered through README.md and CONTRIBUTING.md. Each decision is a block with Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records the public API contract and its limitations. AGENTS.md and backlog.tasks now point at the new home. --- AGENTS.md | 3 +- backlog.tasks | 14 ++- development/README.md | 85 +++++++++++++ development/ci.md | 146 ++++++++++++++++++++++ development/library.md | 116 ++++++++++++++++++ development/publishing.md | 140 +++++++++++++++++++++ development/testing.md | 76 ++++++++++++ development/tooling.md | 247 ++++++++++++++++++++++++++++++++++++++ development/workflow.md | 227 +++++++++++++++++++++++++++++++++++ 9 files changed, 1049 insertions(+), 5 deletions(-) create mode 100644 development/README.md create mode 100644 development/ci.md create mode 100644 development/library.md create mode 100644 development/publishing.md create mode 100644 development/testing.md create mode 100644 development/tooling.md create mode 100644 development/workflow.md diff --git a/AGENTS.md b/AGENTS.md index cdaa80c..54a6364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,5 +64,6 @@ In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CO - [CONTRIBUTING.md § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong. - [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72. - [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI). -- [README.md § Tooling decisions](./README.md#tooling-decisions) — the rationale behind each tool choice; read before changing tooling. +- [development/](./development/README.md) — the decisions, rejected alternatives and known issues behind the rules; the “why” that CONTRIBUTING.md links to. Read the relevant file before changing an area. +- [development/tooling.md](./development/tooling.md) — the rationale behind each tool choice; read before changing tooling. - [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project). diff --git a/backlog.tasks b/backlog.tasks index e8b4f12..faed495 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -27,12 +27,18 @@ Documentation: ☐ Clean up CONTRIBUTING.md and README.md, create docs ☐ Review existing documentation for accuracy and completeness ☐ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors - ☐ A lot of decisions are documented in the current README.md and CONTRIBUTING.md, that are not part of a main entry for users, how to use the library nor part of a main entry for contributors, - ☐ evaluate a new structure for the docs, and move the relevant information to the new docs + ☐ Move the decisions, shortcomings and known issues out of README.md and CONTRIBUTING.md + ☐ Decided: category files under development/ (one per area), not ADRs. Each decision is a block with #### Decision (YYYY-MM) / #### Why / #### Rejected / #### Known issue; rationale in development/README.md ☐ have a look at other well known repositories for inspiration on how to structure the docs ☐ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now - ☐ More important is to have the documentation of our decisions, shortcomings and known issues, not sure where to put this. at work we have ADRs, maybe that? Please suggest something after having looked at other well known repos ☐ make sure to preserve that information in the new docs + ☐ development/README.md - index and decision-block convention + ☐ development/workflow.md - branching, script prefixes, feedback tiers, commits + ☐ development/tooling.md - toolchain decisions and editor setup + ☐ development/testing.md - type-driven testing + ☐ development/ci.md - pipeline, runner image, coverage serving + ☐ development/publishing.md - release and npm publishing + ☐ development/library.md - public API design and its limitations ☐ README.md ☐ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license (example: https://metacpan.org/pod/Scalar::Util) @@ -75,7 +81,7 @@ Maintenance: ✔ Log tool-cache state from the job container to find it (temporary, removed once understood) @done ✔ Guard the invariant in CI (`Assert the baked tool cache is present`) @done ✘ Enable force-pull for the runner so a changed act-ci image is never missed @low @cancelled - → decided against: it is acceptable to miss a runner-side image change, and the image is only rebuilt on a Node bump, which changes the tag anyway. A Dockerfile-only change re-pushed under an unchanged tag is a known issue with a manual `docker rmi` workaround (see CONTRIBUTING § CI runner image). + → decided against: it is acceptable to miss a runner-side image change, and the image is only rebuilt on a Node bump, which changes the tag anyway. A Dockerfile-only change re-pushed under an unchanged tag is a known issue with a manual `docker rmi` workaround (see development/ci.md). ✔ Improve CI publish @done ✔ Check whether publish job is only run on tags, if not, guard it @done ✔ Gate only single steps @done diff --git a/development/README.md b/development/README.md new file mode 100644 index 0000000..b52a3ce --- /dev/null +++ b/development/README.md @@ -0,0 +1,85 @@ +# Development documentation + +These files explain **why** this project works the way it does: the decisions +behind it, what was rejected, and the shortcomings and known issues we carry. +They are written for maintainers and contributors of the project itself. + +The actionable rules — how to set up, run, test, and submit — live in +[CONTRIBUTING.md](../CONTRIBUTING.md). This folder is the "why" behind those +rules: read the relevant file here before changing an area, and update it when +a decision changes, rather than leaving an obsolete reason behind. A rule and +its reason drift apart when they live in one place and are maintained in two; +keeping the rule in CONTRIBUTING.md and the reason here keeps each single-homed. + +User-facing documentation lives in [README.md](../README.md). A `docs/` folder +is deliberately not used yet: the convention is that `docs/` is the future +user documentation site, and deploying that site is out of scope. When it +exists, these files stay here — they are not user documentation. + +## Layout + +One file per category: + +| File | Covers | +| -------------------------------- | ----------------------------------------------------------------------- | +| [library.md](./library.md) | Public API design, the type-level contract, and its limitations | +| [workflow.md](./workflow.md) | Branching and merging, script prefixes, feedback tiers, commit messages | +| [tooling.md](./tooling.md) | Toolchain choices and configuration, editor setup | +| [testing.md](./testing.md) | Test strategy and type-driven development | +| [ci.md](./ci.md) | CI pipeline, runner image, coverage serving | +| [publishing.md](./publishing.md) | Release and npm publishing | + +A category that outgrows one file becomes a folder with an index, but we start +with one file per category because each area is small enough to hold in mind at +once. When a category splits, the links in CONTRIBUTING.md and README.md keep +pointing at the category, not at a single decision. + +## Decision blocks + +Every non-obvious choice is recorded as a block inside the relevant category +file, using this shape: + +```md +## Runner image + +#### Decision (2026-09) + +Bake Node into the CI job image at the setup-node tool-cache layout instead +of downloading per job. + +#### Why + +- ... + +#### Rejected + +- Gitea Pages / per-job download +- force-pull + +#### Known issue + +- a Dockerfile-only change re-pushed under an unchanged tag is invisible to the runner + - recover with `docker rmi ` +``` + +- The date is the month the decision was made, not the date the file was last + edited. It is the anchor a reader uses to tell "this is current" from "this + was current once". +- `Rejected` is what makes the record worth keeping: it stops the project from + re-litigating the same alternatives. An empty `Rejected` usually means the + alternatives were never written down. +- `Known issue` is where shortcomings live. If a caveat is not tied to one + decision, put it under a `## Known issues` section at the end of the file. +- A superseded decision is **replaced in place**, not archived in a second + file — the file always describes the current world, and git history is the + archive. + +## Adding to these docs + +1. Pick the category file for the area you are changing: `workflow`, `tooling`, + `testing`, `ci`, or `publishing`. +2. Add or update a decision block. Keep the existing text unless the decision + actually changed. +3. If the change alters an actionable rule, update + [CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link the + two. diff --git a/development/ci.md b/development/ci.md new file mode 100644 index 0000000..796825e --- /dev/null +++ b/development/ci.md @@ -0,0 +1,146 @@ +# CI + +The pipeline definition is [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml); +that file, not this prose, is the source of truth for the job graph. This file +records why the pipeline and its runner image are shaped the way they are. + +## Pipeline + +- **`build`** (push to `main` / tag) — build + correctness + packaging. +- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; it reports + and never fails the build. +- **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`, + then the Gitea release page and `npm publish`. See + [publishing.md](./publishing.md). +- **`release-gate`** — recognizes a `:rocket: Release x.y.z` commit and skips the + `build`/`maintain` jobs, because `create:release` pushes the tag for the exact + same commit right after and the tag run is authoritative. It uses no Node and + stays on the runner's default image. + +## Runner image + +#### Decision (2026-09) + +The `build` / `maintain` / `publish` jobs run in +`gitea.e1nsnull.de/tmu/act-ci:` +([docker/Dockerfile](../docker/Dockerfile)) — the runner's default act image +with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout +`actions/setup-node` probes before downloading. + +#### Why + +- No job pays the ~50 MB Node fetch, because the probe hits the baked entry. +- The image tag must equal the exact version pinned in + [.node-version](../.node-version), and the image is rebuilt only as part of a + Node bump — there is no other trigger. +- Building requires a docker daemon and registry credentials, so it belongs to + no feedback tier. This is why it is deliberately **not** an `npm run` script: + per the [script prefix convention](./workflow.md#script-prefix-convention), no + existing prefix fits and that is the signal. + +#### Rejected + +- Downloading Node in every job: the ~50 MB fetch per job was the original + problem. +- Gitea Pages / Codecov for coverage: neither was confirmed available or wanted + (see [Coverage serving](#coverage-serving)). + +## Bumping Node + +Bumping Node is one coordinated change, committed as a unit: + +1. Edit [.node-version](../.node-version) to the new exact `x.y.z` — floats like + `26` resolve to the latest patch at runtime and silently bust the baked + entry; [scripts/runner-image.sh](../scripts/runner-image.sh) refuses them. +2. `docker login gitea.e1nsnull.de` (user + package/access token), then + `./scripts/runner-image.sh --push` — it reads the version from `.node-version` + and builds/pushes `:`. +3. Repoint the three `container.image` tags in + [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to the same version. + +Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the +per-job download. + +## Image invariants + +Two invariants the image must satisfy for the `setup-node` probe to hit, both +easy to break: + +### The `x64.complete` marker + +#### Decision (2026-09) + +Bake a `/.complete` marker next to the Node directory. + +#### Why + +- `actions/tool-cache` accepts a cached tool only when + `/.complete` exists next to the directory (`tc.find()` checks + it). A plausible-looking `node//x64/` alone is ignored and the + download happens anyway. See the comment in + [docker/Dockerfile](../docker/Dockerfile). + +### Tag freshness, with force-pull deliberately off + +#### Decision (2026-09) + +Leave `act_runner`'s `force_pull` disabled. + +#### Why + +- The tag encodes only the Node version, and the image is rebuilt only when that + version changes — so the normal flow always yields a new tag and the runner + pulls it. +- Forcing a pull would re-pull the image on every job for no benefit. + +#### Rejected + +- Enabling `force_pull`: it is acceptable to miss a runner-side image change, + and a `Dockerfile`-only change is not worth a per-job pull. + +#### Known issue + +- A `Dockerfile`-only change (like the marker above) re-pushed under an + unchanged tag is invisible to the runner — it keeps the old image while the + registry shows the new digest. If that ever matters, remove the stale tag on + the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not + reach for force-pull. + +## Coverage serving + +#### Decision (2026-09) + +Serve CI coverage from a shared directory on the runner, with no deploy step in +CI. + +#### Why + +- The webserver just exposes the shared directory; the Gitea docker setup reuses + the existing reverse proxy. There is no upload artifact and no external + service. +- Coverage is written to a shared volume keyed by project and tag (for example + `/docs/tiny-pattern-ts//`). + +#### Rejected + +- Gitea Pages and Codecov: neither was confirmed available or wanted. + +#### Known issue + +- Coverage is currently served for tag pushes only. Serving it for non-tag + pushes (for example `main/coverage`) is tracked separately. + +[scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst` +sidecars next to every text asset under the served directories. The Gitea pages +service (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the +sidecar matching `Accept-Encoding` and falls back to the original for the rest. + +#### Decision (2026-09) + +Precompress text assets into sidecars rather than compressing on each request. + +#### Why + +- The files are static and change only on deploy, so the work is paid once. +- Images, fonts and archives are already compressed; a sidecar would only make + them bigger, which is why only text extensions are emitted. diff --git a/development/library.md b/development/library.md new file mode 100644 index 0000000..6407b89 --- /dev/null +++ b/development/library.md @@ -0,0 +1,116 @@ +# Library design + +The type-level design of the public API, and the limitations it carries. The +user-facing reference is [README § API](../README.md#api); this file records why +the API is shaped the way it is. + +## A type guard is the single primitive + +#### Decision (2026-09) + +Every pattern constructor returns a `Matcher` whose only member is +`matches: (value: unknown) => value is T`. `Pattern` is an alias for +`Matcher`. + +#### Why + +- A type guard is the one TypeScript construct that both narrows in an `if` and + composes into a chain, so the whole library can be built from it without a DSL + or transpiler. +- Because `matches` narrows, `match(value).with(pattern, handler)` can give the + handler the right type with no cast and no runtime type tag. +- Keeping `Pattern` as an alias means a caller can name either; the type is + identical. + +## The builder is immutable + +#### Decision (2026-09) + +`match(value)` returns a builder whose `.with` returns a _new_ builder rather +than mutating the current one. + +#### Why + +- A partially built chain can be stored and reused without one call site + affecting another. +- `.with` widens the result type `R` to `R | V`; a value that changes type in + place cannot be represented soundly. A new value per `.with` is what makes the + type-level accumulation work. + +## `exhaustive()` is a runtime check + +#### Decision (2026-09) + +`.exhaustive()` throws at runtime when no case matched. It does not statically +prove that every member of the input union has a case. + +#### Why + +- TypeScript cannot require a fluent call chain to cover every union member + without a compiler plugin or a builder whose type tracks an uncovered-member + set. That machinery is out of proportion to the library's size. +- The union-of-return-types (`R`) is still type-safe; what is not guaranteed is + that the chain is complete. + +#### Known issue + +- A missing case is a runtime `Error`, not a compile error. A caller who wants + compile-time safety must either add a `.otherwise(...)` (which never throws) or + prove coverage themselves. Making the builder track uncovered members is a + possible future change; it is not in scope now. + +## `P.type` takes the type as a parameter + +#### Decision (2026-09) + +`P.type(name)` requires the caller to supply `T` explicitly; it is not +inferred from the `typeof` string. + +#### Why + +- A runtime string cannot carry a TypeScript type. The caller pairs the two, and + the compiler only checks that `T` is used consistently afterwards. + +#### Known issue + +- The type parameter and the runtime name can disagree (`P.type("string")` + compiles), because nothing ties `T` to `name`. `P.when` with a real type guard + avoids the pairing entirely, so prefer it. A future revision could map the name + to a type with a conditional type; that is not in scope now. + +## `P.shape` narrows only through `refine` + +#### Decision (2026-09) + +`P.shape(shape, refine?)` returns `Matcher`, defaulting to +`Matcher` when no `refine` is given. + +#### Why + +- The shape object's values may be matchers, so the shape's literal type is not + the matched type: `S` describes the check, not the value. A `refine` type guard + is the explicit point where the value is narrowed to `T`. +- Checking keys with `in` (rather than requiring exact keys) lets a shape match a + wider object, which is how discriminated unions are handled. + +#### Known issue + +- Without `refine`, a discriminated-union case does not narrow, so `P.shape` + on its own is easy to misuse. The README shows the `refine` form; prefer + `P.when` when a guard is already available. + +## `P.any` exists for non-guard predicates + +#### Decision (2026-09) + +`P.any(predicate)` accepts a boolean predicate and a declared `T`. + +#### Why + +- Some checks are cheap to write as a boolean but awkward as a type guard (for + example an `every` over an array). `P.any` is the escape hatch for those. + +#### Known issue + +- Like `P.type`, the declared `T` is not proven by the predicate. Prefer + `P.when` whenever the check can be written as a guard. diff --git a/development/publishing.md b/development/publishing.md new file mode 100644 index 0000000..324f509 --- /dev/null +++ b/development/publishing.md @@ -0,0 +1,140 @@ +# Publishing + +Publishing is CI-only by policy. Local `npm publish` is not supported. The +maintainer triggers releases from `main`; the mechanics live in +[scripts/release.sh](../scripts/release.sh) and +[scripts/release-notes.sh](../scripts/release-notes.sh), and the job graph lives +in [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml). + +## Release steps + +1. All intended changes are merged to `main` and passing CI. +2. The maintainer runs `npm run create:release`. VS Code opens + [CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because + pubv refuses a dirty tree, any edit is committed first (then folded into the + release commit), and pubv's interactive prompt suggests a version from those + notes — the maintainer confirms or edits it. +3. `scripts/release.sh` creates a single release commit (graduated changelog + + `package.json` bump, amended into one commit), tags it, and pushes everything + to Gitea. +4. CI fires on both pushes: the `publish` job runs on the tag (`build` + + publish-tier checks + release page + `npm publish`), while the branch run's + `release-gate` job recognizes the release commit and skips + `build`/`maintain` — the tag verifies the identical SHA, so no work is + duplicated. The publish-tier checks must pass before the artifact is + published. The `publish` job also creates the Gitea release page from the + matching Keep-a-Changelog section; it runs _before_ `npm publish` so a broken + page fails CI without consuming a version, and `npm publish` stays the last + step. + +## CI-only publishing + +#### Decision (2026-09) + +Releases are cut by CI from `main`; there is no local `npm publish` and no +`publish:*` script in the `check` chain. + +#### Why + +- The tag is the artifact marker: CI verifies the exact commit it points at, so + a local publish could ship something the tag does not describe. +- `publish:publint` / `publish:attw` validate the _publishable artifact_, which + needs a fresh build; they are not source-correctness checks, so they do not + belong in `check`. + +## The release commit is assembled from two tools + +#### Decision (2026-09) + +`create:release` uses `pubv` for the changelog graduation and bump heuristic, +then `npm version` for the `package.json` + lockfile bump, amended into a single +release commit. + +#### Why + +- We want hand-written Keep-a-Changelog notes, an `[Unreleased]` -> + `## [x.y.z] - DATE` graduation, and a tag that marks the exact commit on + `main` that gets published. +- No single tool did both the `[Unreleased]` graduation _and_ the + `package.json` bump. Splitting by strength: `pubv` (tiny, changelog-driven) + owns preflight, the interactive major/minor/patch heuristic, and graduating and + committing `CHANGELOG.md` (no tag, no push); `npm version` syncs + `package.json` + the lockfile; `--amend` folds them into pubv's single commit; + the tag is created _after_ the amend so it is never orphaned. +- The notes are finalized in VS Code _before_ pubv: the `[Unreleased]` body is + what pubv's bump heuristic reads, so editing afterwards would inform the + changelog only, not the version choice. The staging commit that makes pubv + accept the tree is folded back into the single release commit. + +#### Rejected + +- The conventional-commits family: the history is gitmoji, not Conventional, and + we want hand-written notes (see + [workflow.md § Commit messages](./workflow.md#commit-messages)). +- `changesets` / `rtk`: config plus a heavier version/publish flow that fights + the CI-only publish. +- `knope` / `kacl` / `bestikk`: changelog-only — they don't bump `package.json` + — and they carry 5-year / 2-year / brand-new maintenance. +- `pubv` alone: verified it never writes `package.json`. +- `versions` (silverwind): great Gitea support, but pairing it with a hand-rolled + promote became a ~180-line script to maintain, which is exactly what this + ~30-line version replaces. + +## The version has one source of truth + +#### Decision (2026-09) + +The version is derived from the graduated `## [x.y.z]` heading in +[CHANGELOG.md](../CHANGELOG.md) and written to `package.json` + +`package-lock.json` by `npm version`. The README carries no version line and +does not link `package.json`. + +#### Why + +- `release.sh` reads the version from the changelog, so the changelog is the + input and `package.json` is the derived copy — one direction, no drift. +- npm and Gitea render the version from package metadata, so a README copy would + be a third place to update for no reader benefit, and a link would only move + the lookup without removing the copy. + +#### Rejected + +- A `## Version` line in the README: it would make `release.sh` responsible for + a third file. +- Linking `package.json` from the README: it invites a hand-maintained duplicate + that the link does not keep in sync. + +## Release notes are extracted from the changelog + +#### Decision (2026-09) + +`scripts/release-notes.sh ` prints the Keep-a-Changelog section for the tag +and exits non-zero when the section is missing. + +#### Why + +- CI reuses the release body from the same file that drove the version, so the + page and the changelog cannot disagree. +- Failing on a missing section means a release can never publish with an empty + body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match the + `## [1.2.3]` heading. + +## Token gates + +#### Decision (2026-09) + +The Gitea release page uses the run's automatic token (`github.token`). +`npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env`. A final +`always()` step fails the job unless both halves reported `success`. + +#### Why + +- The automatic token only needs `contents: write`, so no secret gate is needed + for the release page. +- `secrets` is not an allowed context in a step `if`, so `NPM_TOKEN` must be + lifted into job-level `env`; an unset secret then skips the publish instead of + attempting an unauthenticated one. +- A tag is all-or-nothing: without the `always()` guard a skipped or failed npm + half would leave the job silently green. The guard turns it red. + +Set `NPM_TOKEN` (npm publish rights) under Settings -> Actions -> Secrets. diff --git a/development/testing.md b/development/testing.md new file mode 100644 index 0000000..0b11b25 --- /dev/null +++ b/development/testing.md @@ -0,0 +1,76 @@ +# Testing + +For this library the types _are_ the feature — narrowing, `exhaustive()` +returns, the `Matcher` contract — so a runtime-only test loop would verify +the wrong thing. The contributor-facing commands are in +[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped +the way it is. + +## Type-driven development + +New behavior follows **type-driven development** (in Edwin Brady's sense): +_treat the type as the plan for a program, and use the compiler and type checker +as your assistant, guiding you to a complete program that satisfies the type_ +([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the +`expectTypeOf` assertion, written first. The loop is **type → red → green → +refactor**: + +1. **Type** — write the compile-time expectation first + (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on + the _type_. The type error is the spec you want to hit before the runtime + logic exists. +2. **Red** — add the matching runtime assertion (`assert.*`) so + `npm run test:unit` now fails on behavior. +3. **Green** — implement in `src/*.ts` until both the type check and the test + pass. +4. **Refactor** — with the type system and the tests as the safety net, then + `npm run verify` as the definition-of-done gate. + +This is why every test in the suite pairs an `expectTypeOf(...)` with an +`assert.*` — keep them together. + +#### Decision (2026-09) + +Test-driven development is _type_-driven here: the compile-time expectation is +written before the runtime assertion, and both before the implementation. + +#### Why + +- A runtime-only test can pass while the type is wrong, and a type-level library + would then ship a broken feature that its tests bless. +- The type error is a more precise spec than a failing assertion, because it + states the exact expected type before the logic exists. + +#### Rejected + +- Runtime-first (classic red/green): it verifies the value, not the contract, + and the contract is the product. +- Testing the type only: it would not catch handler wiring, `exhaustive()` + throwing, or the `otherwise` fallback (see `src/index.test.ts`). + +## Enforcement + +Type-first is also enforced structurally: `npm test` runs `check:tsc` before the +test runner, so a wrong type can never be papered over by a passing assertion. +Per [AGENTS.md § Never do](../AGENTS.md#never-do), reach green honestly — fix the +types so both the type check and the runtime assertion pass, never suppress the +ones you can't make pass. + +## Test tiers + +- `npm run test:unit` — the test runner alone, for the fast local loop. +- `npm test` — `check:tsc` + the unit suite; this is what pre-push and the + baseline check run. +- `npm run test:ci` — adds c8 coverage; used by CI. `c8` uses V8 coverage, so + the `--strip-types` source is instrumented without a build step. + +The runner is `node --test --strip-types "src/**/*.test.ts"`. It relies on the +`.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)). + +#### Known issue + +- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, + so `src/index.test.ts` carries a file-level `oxlint-disable +typescript/no-floating-promises` with an explanatory comment. This is a known + false positive, not a rule we want off project-wide (see + [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). diff --git a/development/tooling.md b/development/tooling.md new file mode 100644 index 0000000..4ebf711 --- /dev/null +++ b/development/tooling.md @@ -0,0 +1,247 @@ +# Tooling + +The choice and configuration of every tool below is the result of a deliberate +trade-off, not a default. This file is the record of those trade-offs. The +commands a contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md); the +tool versions are in [package.json](../package.json). + +## Tool inventory + +- **TypeScript 7** — type checker and build (`tsc`). +- **node --test** + `--strip-types` — test runner. +- **c8** — code coverage for `test:ci`. +- **oxlint** — Rust-based linter, with type-aware rules powered by + **oxlint-tsgolint** (typescript-go). +- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, + JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting + replaces `sort-package-json`. +- **cspell** — spell checking. +- **knip** — finds unused dependencies, exports, and files. +- **check-outdated** — reports dependencies behind the registry; it exits + non-zero whenever _any_ dependency is outdated. +- **publint** — validates `package.json` for ESM publishing correctness. +- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against + multiple module-resolution scenarios. +- **lefthook** — git hooks. +- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents + (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via + `tsc --lsp --stdio`. + +When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers). + +## TypeScript and build + +### One type-check config, one emit config + +#### Decision (2026-09) + +`tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`. +`tsconfig.build.json` extends it to add the emit-only options (`declaration`, +`sourceMap`, `inlineSources`, `outDir`, `target: es2024`, +`rewriteRelativeImportExtensions: true`) and to exclude test files. + +#### Why + +- It lets the editor and CI type-check from one config while the build emits + from the other, so a test file cannot leak into `dist/`. +- `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so + debuggers can map into `src/` without it being shipped. +- `declarationMap` is intentionally off: a `.d.ts.map` cannot embed source and + would dangle. + +### The build starts from an empty `dist/` + +#### Decision (2026-09) + +`npm run build` first runs a `prebuild` hook that empties `dist/`. + +#### Why + +- `tsc` does not prune orphaned emit output. Dropping `declarationMap`, for + example, left stale `*.d.ts.map` files behind, so the build must start from an + empty `dist/` to be reproducible. +- `prebuild` removes only `dist`; the manual `clean` still resets `dist` + + `coverage`, so a local coverage report survives a build. + +### Source imports use `.ts` extensions + +#### Decision (2026-09) + +Source imports use `.ts` extensions, never `.js`. + +#### Why + +- `node --strip-types` only resolves the `.ts` form at test time. +- `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them + to `.js` in the emitted JavaScript. +- The emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves + (see [README § Requirements](../README.md#requirements)), so no + post-processing step is needed. + +#### Rejected + +- "Pre-fixing" an import to `.js`: it breaks the inner `node --strip-types` + loop. + +## Linting and formatting + +### Type-aware oxlint is a config property + +#### Decision (2026-09) + +Type-aware oxlint is enabled declaratively via `options.typeAware: true` in +`.oxlintrc.json` (powered by `oxlint-tsgolint`). + +#### Why + +- The script commands stay clean — no CLI flag. +- Type-aware mode is a property of the config, not the invocation, so it cannot + be forgotten on one call site. + +#### Rejected + +- Passing a CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the + mode in two places and invites them to drift. + +### `oxlint-disable` directives live next to the code + +#### Decision (2026-09) + +Source-level `oxlint-disable` directives are used for known type-aware false +positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), rather +than rules being disabled in `.oxlintrc.json`. + +#### Why + +- The disable lives next to the code it silences, so the trade-off is visible to + anyone reading the source. + +#### Rejected + +- Silencing a rule project-wide in `.oxlintrc.json`: it hides the suppression + from the reader of the affected code. + +#### Known issue + +- A source-level disable is a _human_ last-resort convention. AI coding agents + must not add one; they fix the type at its root instead (see + [AGENTS.md § Never do](../AGENTS.md#never-do)). + +### `check:tsc` runs first + +#### Decision (2026-09) + +`check:tsc` runs first in the `npm run check` chain. + +#### Why + +- A type error short-circuits the rest, which is faster feedback than letting + oxlint/oxfmt run and then failing on `tsc` at the end. + +### `.editorconfig` is a fallback, not a gate + +#### Decision (2026-09) + +`.editorconfig` exists for editor compatibility. Where both apply, +`.oxfmtrc.json` is authoritative. + +#### Why + +- `.editorconfig` is a sane fallback for the files oxfmt does not format (shell + scripts, dotfiles, `LICENSE`, the commit-message template, and git's + `COMMIT_EDITMSG` buffer). +- oxfmt is the formatter; the overlapping `.editorconfig` keys only keep + non-oxfmt editors close to the formatted result, so they cannot disagree with + the checker. + +## Static analysis and packaging + +### `knip` omits the `types` category + +#### Decision (2026-09) + +`knip --include dependencies,exports,files` intentionally omits the `types` +category. + +#### Why + +- The `types` category produces systematic false positives for libraries whose + exported types are part of the public API. +- The targeted scope keeps the signal high without config-file boilerplate. + +### `attw` targets ESM-only + +#### Decision (2026-09) + +`attw --profile esm-only` is used. + +#### Why + +- It is semantically correct: this package is intentionally ESM-only (no + CommonJS shim), so CJS resolution scenarios are out of scope by design, not a + bug. + +### `tslib` and `type-fest` are deliberately not used + +#### Decision (2026-09) + +Neither `tslib` nor `type-fest` is a dependency. + +#### Why + +- `tslib` is a runtime helper for old ES3/ES5 targets; the project targets + ES2024. +- `type-fest` was never imported. +- `knip` flagged both, which is the same signal that keeps the list honest. + +## Git hooks and script wiring + +### `LEFTHOOK_FILES` scopes commands to staged files + +#### Decision (2026-09) + +The pre-commit hook sets the `LEFTHOOK_FILES` env var to the staged-files list, +and the affected scripts use `${LEFTHOOK_FILES:-}` to default to the +whole project when invoked manually. + +#### Why + +- It keeps `package.json#scripts` as the single source of truth for the + underlying commands — `lefthook.yml` only describes _what to run on which + files_. +- The same script works by hand (whole project) and staged (scoped), so there is + no second command to maintain. + +## Editor and agent tooling + +### VSCode integration + +- Recommended extensions: see + [.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript + native-preview, EditorConfig, todo-tasks). +- TypeScript 7 is used via the `typescriptteam.native-preview` extension. +- The oxc extension provides oxlint squiggles and oxfmt format-on-save; + `.vscode/settings.json` pins it per language so a user's local `[language]` + formatter settings cannot override the project's choice. + +### `@spences10/pi-lsp` is pinned and read-only + +#### Decision (2026-09) + +`@spences10/pi-lsp` is pinned to `0.0.46` and used read-only. + +#### Why + +- The package inspects `node_modules/typescript`, sees major >= 7 with no + `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the + repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` + dependency is required. +- Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server +--stdio` and are TS6-only. +- The tool is _intermediate_ agent feedback (hover, references, definition, + symbols, diagnostics). It has no rename / code-action / apply-edit surface, + and is never a correctness gate — `npm run check` / `verify` remain that. +- `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a + gitignored install cache that pi recreates automatically on a trusted startup + (it runs `npm install` for any missing project package), so the cache is + deliberately not tracked. diff --git a/development/workflow.md b/development/workflow.md new file mode 100644 index 0000000..7bba966 --- /dev/null +++ b/development/workflow.md @@ -0,0 +1,227 @@ +# Workflow + +How work moves through the repository: the branching model, the `npm run` +script taxonomy, the feedback tiers, and commit messages. The contributor-facing +steps are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they +are shaped the way they are. + +## Branching model + +**GitHub Flow (single-developer).** Every change — feature, fix, refactor — +branches off `main` and is merged back via a local commit. There is no pull +request workflow on Gitea yet: collaborative review through the Gitea UI is not +in place, so Gitea is the lab. When something is tested and ready for +production it will be promoted to GitHub. + +- **Base branch:** `main` +- **Branch naming:** `feature/` / `fix/` / `chore/` +- **Starting work:** `npm run create:branch -- /`. It refuses, + without changing anything, unless the working tree is clean (untracked files + included), no merge/rebase/cherry-pick is in progress, `main` matches its + upstream, and `npm run test` is green on `main` — so a later failure is always + attributable to your edits. The prefix is still _your_ call, inferred from the + task; the script validates it rather than guessing it. +- **Merging:** `npm run create:finish` (on the branch). It asserts the same + clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale + `main` (a true divergence is refused), merges the branch `--no-ff`, runs + `npm run verify`, and deletes the branch only after the merge is green. The + push is deliberately left to `create:release`, so the merge stays local and + reviewable — read the diff yourself before finishing. +- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is + the authoritative gate. The one exception: a push headed by a release commit + (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because + `create:release` pushes the tag for that exact commit right after and the tag + run is the authoritative one (see `release-gate` in + [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml)). +- **Releases are NOT triggered by pushes.** Only the maintainer triggers a + release (see [publishing.md](./publishing.md)). + +#### Decision (2026-09) + +Work is opened and closed by `create:branch` / `create:finish` rather than by +prose plus hand-written `git` commands. + +#### Why + +- The branching model's preconditions were prose. Prose rots silently — a rule + nobody checks is a suggestion. A script asserts, then acts, and the branch or + merge only happens if the assertions passed. +- The type-driven loop only produces trustworthy results if the baseline was + green before the first edit. Cheap checks run first and `npm run test` last, so + the expensive gate is not paid on a tree that was never eligible. +- `create:finish` owns the merge-side preconditions and the post-merge + `npm run verify`, so a merge cannot land unverified. The push stays with + `create:release` so the merge remains local and reviewable until then. +- Every check failure is non-mutating, except the baseline test which runs on + `main` after switching there — a red `main` restores the branch you started on + rather than stranding you on it. A merge conflict aborts and returns to the + feature branch, so a failed finish never leaves a half-merged `main`. + +#### Rejected + +- Hand-written `git switch -c` / `git merge`: same rules, no enforcement. +- Reusing `pubv`'s preflight for `create:branch`: it is release-shaped and + third-party, and would make starting a branch pay for a build and pack it has + no use for. +- Leaving the merge as reviewer judgment: that judgment still exists, but it now + happens when the maintainer reviews the handover _before_ invoking + `create:finish`, rather than being encoded in a command that anyone can run + from a dirty tree. +- `--no-ff` rather than a fast-forward: it keeps each unit of work visible in + `git log`. + +#### Known issue + +- `create:finish` deliberately does not push, so `main` is ahead of + `origin/main` between a merge and the next push or release. `create:branch` + requires `main` to match its upstream and will refuse until it is pushed. Push + `main` before starting the next branch. + +## Script prefix convention + +Script names in `package.json` use a prefix that signals _when_ the script is +intended to run. A `:` script is implicitly aggregated by a +`` script (if one exists) and run by the corresponding lefthook hook or +CI step. Picking the right prefix documents the script's intended lifecycle: + +- `create:*` — front doors of the repo's own workflow; these mutate git state + rather than the source. `create:branch` opens a unit of work, `create:finish` + closes the branch half, `create:release` closes the release half + (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` + for the precedent. +- `check:*` — read-only verification; never modifies files. Aggregated by + `npm run check`. +- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by + `npm run fix`; the diff is the review surface. +- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + + unit tests); `test:unit` skips the typecheck for fast local iteration; + `test:ci` adds c8 coverage. +- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by + `watch`; currently a single child (`watch:test`), and a future + `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella. +- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project + and/or network-bound, so never a correctness gate. Aggregated by + `npm run maintain`. +- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than + the source, so it needs a fresh build. +- `setup:*` — one-time configuration of a fresh clone; mutates the local + environment (git config, editor settings) rather than the repo source, so it + is never part of a hook or CI step. Aggregated by `npm run setup` (the + umbrella), run once after cloning. + +A new script should pick the prefix that matches its lifecycle, not invent a new +one. If no existing prefix fits, that's a signal the script doesn't belong in +the standard pipeline. When it genuinely does belong, a new prefix is allowed — +but it enters both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in the same +commit as its first member, otherwise the rule "reuse an existing prefix" +silently develops an exception. + +Separately, some top-level scripts are **bare** (no prefix): the entry points +that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family +(`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that +composes across tiers: `verify` — composing `check` + `test:unit` into one +whole-project correctness gate (it deliberately uses `test:unit` rather than +`test` because `check` already runs `check:tsc`, so the type checker runs +exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts +are what those tiers are made of. + +#### Decision (2026-09) + +`create:` is the prefix for workflow front doors, and there is no bare `create` +aggregator. + +#### Why + +- Both members really do create something: a branch, a release. +- The prefix was added to both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in + the same commit as its first members, because a prefix missing from those + lists is invisible — which was the `use:` mistake this repo previously + carried (since retired into `setup:`). +- `publish:*` already set the precedent for a prefix without an aggregator. + +#### Rejected + +- `run:` / `perform:` — both mean only "do the thing named after them", so every + script in the repo would fit under them and the taxonomy collapses. +- `git:` — names the tool, not the lifecycle moment, and advertises passthrough + aliases. +- `start:` — describes the branch half, not the release. +- `cut:` — idiomatic for both, but it needs VCS slang to decode, and a signpost + that has to be explained is not one. +- `flow:` — overloaded in a library about type-level matching. +- The existing families: `check:*` is read-only and aggregated by `check`, so CI + would run a command that mutates repo state; `fix:*`'s review surface is a file + diff, not a branch; `maintain:*` is advisory and explicitly never a gate. + +## Feedback tiers + +The tools are organized into a feedback ladder. Each tier catches different +things at different costs; the rule of thumb is "earlier tiers fire more often, +faster tiers catch less, slower tiers are more thorough". The tier table itself +lives in [CONTRIBUTING.md](../CONTRIBUTING.md#feedback-tiers); this section +explains why the split is where it is. + +#### Decision (2026-09) + +Split fast, offline, staged-file checks into pre-commit; whole-project test runs +into pre-push and `verify`; and slow or network-bound scans into `maintain`. + +#### Why + +- **`watch:*` is a manual tier, not a hook.** The developer starts it on demand + (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It + sits as the earliest tier in the ladder, catching failures the moment a file is + saved — before staging, before commit. +- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in + pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally + scope to staged files via the `LEFTHOOK_FILES` env var convention. They give + instant feedback on what you typed. +- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole + test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention + doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push + runs after all commits are made but before the push leaves the machine, + catching regressions that span multiple commits. + +#### Rejected + +- Running `maintain:*` in `check` or pre-commit: advisory, whole-project and + network-bound scans are not correctness gates and would make the fast tier + slow. +- Treating a green pre-commit as the definition of done: it only sees staged + files, which is why `npm run verify` exists as the one-shot whole-project gate. +- A separate `git push` hook for `verify`: it would duplicate the pre-push test + tier; `verify` is run by hand because the push is where the whole project is + already checked. + +Before pushing, run `npm run verify` — the one-shot correctness gate. Run +`npm run maintain` only on a maintenance / update-deps branch. + +## Commit messages + +Gitmoji subject, imperative mood, 50/72 wrapping. The template is +[commit-message-template](../commit-message-template); run +`npm run setup:git-commit-message` once after cloning to register it as git's +`commit.template` (or `npm run setup` to run every one-time clone step). + +Examples from history: `:sparkles: Add watch tier with watch:test child`, +`:recycle: Move type-aware config to .oxlintrc.json; use source-level disable +directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The +body explains _what and why_, not _how_; link issues with `Resolves #...`. + +#### Decision (2026-09) + +Gitmoji subjects with imperative mood, wrapped 50/72, rather than Conventional +Commits. + +#### Why + +- The history is gitmoji, not Conventional, and it predates any commit-lint + tooling; switching would rewrite the convention for no gain. +- The body carries reasoning, which is what a reviewer needs; the subject is a + signpost, not a semantic key. + +#### Rejected + +- Conventional Commits: the release flow uses hand-written Keep-a-Changelog + notes, not generated ones, so the prefix carries no automation value here (see + [publishing.md](./publishing.md)). From 73669ec5b632bf75555962866fe1e43f5b3546cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:27:05 +0000 Subject: [PATCH 03/12] :memo: Rewrite CONTRIBUTING as the contributor entry point Adds Setup, Development commands, Code style and formatting (with the VSCode extension hint) and Submitting changes. The decision prose moves to development/; this file keeps the actionable rules (feedback-tier table, script prefix list, standards) and links to the category files for the why. --- CONTRIBUTING.md | 209 ++++++++++++++++++++++++++++++------------------ 1 file changed, 131 insertions(+), 78 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6514cf9..7f92ae3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,49 +1,36 @@ # Contributing -This document is for maintainers and contributors working on the project itself. End-user documentation is in [README.md](./README.md). The machine entry point for AI coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for the rules below so agents and humans don't diverge. +This document is for maintainers and contributors working on the project +itself. End-user documentation is in [README.md](./README.md). The reasons +behind the rules here — the decisions, rejected alternatives, and known issues — +live in [development/](./development/README.md). The machine entry point for AI +coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for +the rules so agents and humans don't diverge. -## Rules the tools don't enforce +## Setup -CI and review will bounce these even though `npm run check` and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default: +1. Clone the repository. +2. Install Node.js >= 26 — see [.node-version](./.node-version); the exact pinned + version is what CI and the runner image use. +3. `npm install`. +4. `npm run setup` — the one-time clone configuration (currently registers the + commit-message template). -- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` only resolves the `.ts` form at test time; "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions) -- **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention)) -- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions) -- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention)) -- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model)) -- **Work is merged back with `npm run create:finish`, never a hand-written `git merge`.** The command carries the merge-side preconditions (clean tree, current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` after the merge, so a merge cannot land unverified. (see [Branching model](#branching-model)) -- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) +## Development commands -## Editor configuration - -`.editorconfig` is for editor compatibility, not a gate — it 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). Where both apply, `.oxfmtrc.json` is authoritative: oxfmt is the formatter, and the overlapping `.editorconfig` keys only keep non-oxfmt editors close to the formatted result. - -## Commit messages - -Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step). - -Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The body explains _what and why_, not _how_; link issues with `Resolves #...`. - -## Script prefix convention - -Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `:` script is implicitly aggregated by a `` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle: - -- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent. -- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`. -- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface. -- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage. -- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`; currently a single child (`watch:test`), and a future `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella. -- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project and/or network-bound, so never a correctness gate. Aggregated by `npm run maintain`. -- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than the source, so it needs a fresh build. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow)) -- `setup:*` — one-time configuration of a fresh clone; mutates the local environment (git config, editor settings) rather than the repo source, so it is never part of a hook or CI step. Aggregated by `npm run setup` (the umbrella), run once after cloning. - -A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline. When it genuinely does belong, a new prefix is allowed — but it enters both lists in this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old `use:` prefix was exactly that; it is now retired into `setup:`). - -Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that composes across tiers: `verify` — composing `check` + `test:unit` into one whole-project correctness gate (it deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of. +- **Build:** `npm run build` +- **Test:** `npm run test`, `npm run test:ci` +- **Watch:** `npm run watch` +- **Checks:** `npm run check`, `npm run fix` +- **Verify:** `npm run verify` — the definition of done +- **Maintenance:** `npm run maintain` — advisory only +- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` ## Feedback tiers -The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough": +The tools are organized into a feedback ladder. Each tier catches different +things at different costs; the rule of thumb is "earlier tiers fire more often, +faster tiers catch less, slower tiers are more thorough": | Tier | When | What it runs | Time | | -------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | @@ -58,62 +45,128 @@ The tools are organized into a feedback ladder. Each tier catches different thin | CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s | -### Why these splits? - -- **`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 feedback 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. - -### Before pushing - -Run `npm run verify` — the one-shot correctness gate in the table above. Run `npm run maintain` only on a maintenance / update-deps branch. +Before pushing, run `npm run verify` — the one-shot correctness gate. Run +`npm run maintain` only on a maintenance / update-deps branch. Why the splits +are where they are: [development/workflow.md § Feedback tiers](./development/workflow.md#feedback-tiers). ## Testing discipline (type-driven) -For this library the types _are_ the feature — narrowing, `exhaustive()` returns, the `Matcher` contract — so a runtime-only test loop would verify the wrong thing. New behavior follows **type-driven development** (in Edwin Brady's sense): _treat the type as the plan for a program, and use the compiler and type checker as your assistant, guiding you to a complete program that satisfies the type_ ([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the `expectTypeOf` assertion, written first. The loop is **type → red → green → refactor**: +For this library the types _are_ the feature, so the loop is **type → red → +green → refactor**: write the `expectTypeOf(...)` assertion first, then the +runtime `assert.*`, then the implementation. Every test pairs the two; keep +them together. Type-first is enforced structurally: `npm test` runs +`check:tsc` before the test runner, so a wrong type can never be papered over by +a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach +green honestly — fix the types, never suppress the checks you can't make pass. +Full rationale: [development/testing.md](./development/testing.md). -1. **Type** — write the compile-time expectation first (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on the _type_. The type error is the spec you want to hit before the runtime logic exists. -2. **Red** — add the matching runtime assertion (`assert.*`) so `npm run test:unit` now fails on behavior. -3. **Green** — implement in `src/*.ts` until both the type check and the test pass. -4. **Refactor** — with the type system and the tests as the safety net, then `npm run verify` as the definition-of-done gate. +## Code style and formatting -This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass. +`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules). +`npm run fix` resolves the fixable issues; `npm run check` verifies without +writing. Suppressions must be fixed at the root — do not add `oxlint-disable` +directives or `as` casts to force a green run (see +[AGENTS.md § Never do](./AGENTS.md#never-do)). + +Suggested VSCode extensions are in +[.vscode/extensions.json](./.vscode/extensions.json); the project's formatter +and linter are wired up there. Toolchain decisions: +[development/tooling.md](./development/tooling.md). + +## Commit messages + +Gitmoji subject, imperative mood, 50/72 wrapping. The template is +[commit-message-template](./commit-message-template); `npm run setup` +(or `npm run setup:git-commit-message`) registers it as git's +`commit.template`. Examples and rationale: +[development/workflow.md § Commit messages](./development/workflow.md#commit-messages). + +## Script prefix convention + +A new `npm run` script must reuse an existing prefix: `create:` / `check:` / +`fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`. If none fits, +that's a signal the script doesn't belong in the pipeline — not a reason to +invent a new prefix. If it genuinely does belong, add the prefix to the list +here in the same commit as its first member; an undocumented prefix becomes +invisible and quietly accrues members. Full convention and why `create:` exists: +[development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention). + +## Rules the tools don't enforce + +CI and review will bounce these even though `npm run check` and the linters +don't catch them. They're the high-frequency things a contributor (or an agent) +reaches for by default: + +- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` + only resolves the `.ts` form at test time; "pre-fixing" an import to `.js` + breaks the inner loop. (why: + [development/tooling.md](./development/tooling.md#source-imports-use-ts-extensions)) +- **A new `npm run` script must reuse an existing prefix.** See + [Script prefix convention](#script-prefix-convention). +- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The + trade-off must sit next to the code it silences. This is a _human_ last-resort + convention; agents must not add these — see + [AGENTS.md § Never do](./AGENTS.md#never-do). (why: + [development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code)) +- **Don't put slow / network / whole-project scans in `check` or pre-commit.** + Advisory scans are not correctness gates; they belong under `maintain:`. (why: + [development/workflow.md](./development/workflow.md#feedback-tiers)) +- **New work starts with `npm run create:branch`, never a hand-written + `git switch -c` / `git checkout -b`.** The command carries the branch + precondition; branching around it skips the clean-tree, current-`main` and + green-baseline checks, and the skip is invisible until a failure can no longer + be attributed. (why: + [development/workflow.md](./development/workflow.md#branching-model)) +- **Work is merged back with `npm run create:finish`, never a hand-written + `git merge`.** The command carries the merge-side preconditions (clean tree, + current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` + after the merge, so a merge cannot land unverified. (why: + [development/workflow.md](./development/workflow.md#branching-model)) +- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` + don't go in `check`.** (why: + [development/publishing.md](./development/publishing.md#ci-only-publishing)) ## Branching model -**GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — Gitea is the lab; when something is tested and ready for production it will be promoted to GitHub. +**GitHub Flow (single-developer).** Every change — feature, fix, refactor — +branches off `main` and is merged back via a local commit. There is no pull +request workflow on Gitea yet. - **Base branch:** `main` - **Branch naming:** `feature/` / `fix/` / `chore/` -- **Starting work:** `npm run create:branch -- /`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it. -- **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing. -- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)). -- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). +- **Starting work:** `npm run create:branch -- /`. It refuses, + without changing anything, unless the working tree is clean, no + merge/rebase/cherry-pick is in progress, `main` matches its upstream, and + `npm run test` is green on `main`. The prefix is _your_ call, inferred from the + task; the script validates it rather than guessing it. +- **Merging:** `npm run create:finish` (on the branch). It re-asserts the same + preconditions, merges `--no-ff`, runs `npm run verify`, and deletes the branch + only after the merge is green. The push is left to `create:release`, so the + merge stays local and reviewable. +- CI runs on every push to `main` — see [Feedback tiers](#feedback-tiers) and + [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml). -## CI runner image +Full rationale, including the front-door decisions and a known issue about +`main` being ahead of its upstream between a merge and the next push: +[development/workflow.md § Branching model](./development/workflow.md#branching-model). -The `build` / `maintain` / `publish` jobs run in `gitea.e1nsnull.de/tmu/act-ci:` ([docker/Dockerfile](./docker/Dockerfile)) — the runner's default act image with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in `.node-version`, and the image is rebuilt only as part of a Node bump — there is no other trigger. `release-gate` uses no Node and stays on the default image. The script is deliberately NOT an `npm run` script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per [Script prefix convention](#script-prefix-convention), no existing prefix fits and that is the signal. +## Submitting changes -Bumping Node is one coordinated change, committed as a unit: +There is no pull request workflow on Gitea yet, so a contribution is submitted +as a branch that is merged locally: -1. Edit `.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` 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 `:`. -3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) to the same version. +1. `npm run create:branch -- /`. +2. Commit your work (one or more commits, per the tests and style rules above). +3. `npm run verify` — the definition of done. +4. `npm run create:finish` to merge the branch into `main` and verify the + result. +5. Present a handover for review. Once there are no further objections, the + maintainer pushes. -Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download. +When the project is promoted to GitHub, this step becomes a normal pull request +against `main`. -Two invariants the image must satisfy for the probe to hit, both easy to break: +## Publishing -- **The `x64.complete` marker.** `actions/tool-cache` accepts a cached tool only when `/.complete` exists next to the directory (`tc.find()` checks it); a plausible-looking `node//x64/` alone is ignored and the download happens anyway. See the comment in [docker/Dockerfile](./docker/Dockerfile). -- **Tag freshness (force-pull deliberately off).** 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 `act_runner` pulls it. `force_pull` stays disabled (the job log shows `forcePull=false`): it is acceptable to miss a runner-side image change, and forcing a pull would re-pull the image on every job for no benefit. Known issue: a Dockerfile-only change (like the marker above) re-pushed under an unchanged tag is invisible to the runner — it keeps the old image while the registry shows the new digest. If that ever matters, remove the stale tag on the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not reach for force-pull. - -## Publishing workflow - -Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`: - -1. All intended changes are merged to `main` and passing CI. -2. The maintainer runs `npm run create:release`. VS Code opens `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 job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. 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 (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step. - -The Gitea release page uses the run's automatic token (`github.token`), so it only needs `contents: write`. `npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env` because `secrets` is not an allowed context in a step `if`: an unset secret skips the publish instead of attempting an unauthenticated one. A tag is all-or-nothing, though — a final `always()` step fails the job unless both the release page and `npm publish` reported `success`, so a skipped or failed npm half turns the job red rather than silently green. Set `NPM_TOKEN` (npm publish rights) under Settings → Actions → Secrets. +Publishing is maintainer-only and CI-only. See +[development/publishing.md](./development/publishing.md). From 9faa0ecc550fcf3669633c6834b3e31af8abf1be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:27:15 +0000 Subject: [PATCH 04/12] :memo: Rewrite README as the user entry point Follows the Perl/CPAN order: name + one-line description, Synopsis, Description, Examples, API reference, License. Tooling, CI and decision content moved to development/; the README keeps only user-facing material and a pointer to CONTRIBUTING.md and AGENTS.md. No version line and no package.json link. --- README.md | 315 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 262 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 3bdec4f..681a3de 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,277 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex). -## Development +## Synopsis -- **Build:** `npm run build` -- **Test:** `npm run test`, `npm run test:ci` -- **Watch:** `npm run watch` -- **Checks:** `npm run check`, `npm run fix` -- **Verify:** `npm run verify` — the definition of done -- **Maintenance:** `npm run maintain` — advisory only -- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` +```sh +npm install tiny-pattern-ts +``` -What each tier runs, when it fires and what it costs: -[CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). How the -`prefix:` in a script name is chosen: -[§ Script prefix convention](./CONTRIBUTING.md#script-prefix-convention). +```ts +import { match, P } from "tiny-pattern-ts"; -### Tooling +const reply = (answer: "yes" | "no") => + match(answer) + .with(P.literal("yes"), (): "agreed" => "agreed") + .with(P.literal("no"), (): "declined" => "declined") + .exhaustive(); -- **TypeScript 7** — type checker and build (`tsc`). -- **node --test** + `--strip-types` — test runner. -- **c8** — code coverage for `test:ci`. -- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go). -- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`. -- **cspell** — spell checking. -- **knip** — finds unused dependencies, exports, and files. -- **check-outdated** — reports dependencies behind the registry; it exits non-zero whenever _any_ dependency is outdated. -- **publint** — validates `package.json` for ESM publishing correctness. -- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. -- **lefthook** — git hooks. -- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via `tsc --lsp --stdio`. - -Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). - -### Tooling decisions - -The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones: - -- **`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. `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so debuggers can map into `src/` without it being shipped; `declarationMap` is intentionally off because a `.d.ts.map` cannot embed source and would dangle. This separation lets the editor and CI type-check from one config while the build emits from the other. -- **`npm run build` first runs a `prebuild` hook that empties `dist/`.** `tsc` does not prune orphaned emit output — dropping `declarationMap`, for example, left stale `*.d.ts.map` files behind — so the build must start from an empty `dist/` to be reproducible. `prebuild` removes only `dist`; the manual `clean` still resets `dist` + `coverage`, so a local coverage report survives a build. -- **Source imports use `.ts` extensions** so `node --strip-types` resolves them at test time. `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them to `.js` in the emitted JavaScript; the emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [Requirements](#requirements)), so no post-processing step is needed. -- **Type-aware oxlint is enabled declaratively** via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). The script commands stay clean — no CLI flag — and type-aware mode is a property of the config, not the invocation. -- **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source. -- **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate. -- **`attw --profile esm-only`** 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. -- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end). -- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_. -- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both. -- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package inspects `node_modules/typescript`, sees major ≥ 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and 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. +reply("yes"); // "agreed" +``` ### Requirements -- Node.js >= 26 (engines field; pinned via `.node-version`). -- TypeScript >= 5.0 to consume the published declarations. The emitted `.d.ts` use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; both resolve on TS >= 5.0 in `node10`/`node16`/`nodenext`/`bundler`. +- **Node.js >= 26** (`engines` field; pinned via `.node-version`). +- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts` + use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; + both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. +- The package is **ESM-only** (no CommonJS shim). -## VSCode integration +## Description -- Recommended extensions: see `.vscode/extensions.json` (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks). -- TypeScript 7 is used via the `typescriptteam.native-preview` extension. -- 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. +`tiny-pattern-ts` gives TypeScript the shape of F#-style pattern matching: +a value flows through a chain of patterns, the first one that matches runs its +handler, and the handler receives the value narrowed to that pattern's type. The +"patterns" are ordinary objects whose `matches` method is a TypeScript type +guard, so narrowing composes the way any other guard does. + +It is deliberately not a regex engine and not a macro. There is no transpiler +and no DSL to learn: `match(value)` returns a builder, `.with(pattern, handler)` +adds a case, and the chain ends in either `.exhaustive()` or `.otherwise(...)`. +The type-level contract is the feature — see +[development/library.md](./development/library.md) for the design decisions and +the known limitations. + +## Examples + +### Literal matching and `exhaustive()` + +`.exhaustive()` returns the union of the handler return types and throws if no +case matched. Annotate handler returns when you want literal types rather than +`string`: + +```ts +type Answer = "yes" | "no"; + +const reply = (answer: Answer): "agreed" | "declined" => + match(answer) + .with(P.literal("yes"), (): "agreed" => "agreed") + .with(P.literal("no"), (): "declined" => "declined") + .exhaustive(); + +reply("yes"); // "agreed" +``` + +`exhaustive()` checks at runtime, not at compile time — TypeScript does not force +every union member to have a case (see +[development/library.md](./development/library.md#exhaustive-is-a-runtime-check)). +Use `.otherwise(...)` when a fallback is wanted: + +```ts +const label = (answer: Answer): string => + match(answer) + .with(P.literal("yes"), () => "agreed") + .otherwise(() => "not agreed"); +``` + +### Matching by `typeof` + +`P.type(name)` pairs an explicit type `T` with the runtime `typeof` name it +should test for: + +```ts +const describe = (value: unknown): string => + match(value) + .with(P.type("string"), (s) => `string of length ${s.length}`) + .with(P.type("number"), (n) => `number ${n.toFixed(2)}`) + .otherwise(() => "something else"); +``` + +The supported names are `string`, `number`, `boolean`, `bigint`, `symbol`, +`undefined`, `object`, and `function`. `"object"` matches non-null objects and +functions; `"undefined"` compares against `undefined` directly. + +### Structural matching and discriminated unions + +`P.shape(shape, refine?)` checks that every key in `shape` exists on the value. +A value that is itself a matcher is applied, otherwise it is compared with +strict equality. To narrow to a concrete type, pass a `refine` type guard: + +```ts +interface Circle { + readonly kind: "circle"; + readonly radius: number; +} + +interface Square { + readonly kind: "square"; + readonly side: number; +} + +type Shape = Circle | Square; + +const area = (shape: Shape): number => + match(shape) + .with( + P.shape({ kind: "circle" }, (v): v is Circle => "radius" in v), + (c) => Math.PI * c.radius ** 2, + ) + .with( + P.shape({ kind: "square" }, (v): v is Square => "side" in v), + (s) => s.side ** 2, + ) + .exhaustive(); +``` + +Without `refine`, `P.shape` returns a matcher for the shape's own type, not the +narrowed one. Nested matchers can be used in the shape object, for example +`P.shape({ name: P.type("string") })`. + +### Custom guards with `when` + +`P.when` takes a type guard and infers the narrowed type from it: + +```ts +const toNumber = (value: unknown): number => + match(value) + .with( + P.when((v): v is string => typeof v === "string"), + (s) => Number.parseInt(s, 10), + ) + .otherwise(() => 0); +``` + +### Widening with `any` + +`P.any(predicate)` takes a plain boolean predicate and a declared type `T`, +for cases where the predicate cannot be written as a type guard: + +```ts +const firstNumber = (items: readonly unknown[]): number | undefined => + match(items) + .with( + P.any( + (v) => + Array.isArray(v) && + v.every((item) => typeof item === "number"), + ), + (xs) => xs[0], + ) + .otherwise(() => undefined); +``` + +## API + +### `match(value)` + +```ts +const match: (value: T) => MatchBuilder; +``` + +Starts a matching chain for `value`. The builder is immutable: every `.with` +returns a new builder, so a partially built chain can be reused. + +#### `.with(pattern, handler)` + +```ts +with(pattern: Matcher, handler: (value: U) => V): MatchBuilder; +``` + +Adds a case. `handler` receives the value narrowed to `U`, and its return type +`V` is added to the builder's result union `R`. A pattern whose narrowed type is +not assignable to the matched value's type is a compile error. + +#### `.exhaustive()` + +```ts +exhaustive(): R; +``` + +Returns the result of the first matching case. Throws +`tiny-pattern-ts: match.exhaustive() called with no matching case` if none +matched. It does not statically prove that every union member is covered. + +#### `.otherwise(handler)` + +```ts +otherwise(handler: (value: T) => R): R; +``` + +Like a final catch-all case: runs `handler` if no earlier case matched. Unlike +`.exhaustive()`, it never throws. + +### `P.literal(value)` + +```ts +const P.literal: ( + value: L, +) => Matcher; +``` + +Matches a single literal with `===` and narrows to its literal type. + +### `P.type(type)` + +```ts +const P.type: ( + type: "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "object" | "function", +) => Matcher; +``` + +Matches a `typeof` result and narrows to the explicitly supplied `T`. `T` is not +inferred from the name, so the type parameter and the runtime name must agree. + +### `P.when(predicate)` + +```ts +const P.when: (predicate: (value: unknown) => value is T) => Matcher; +``` + +Wraps a type guard as a matcher. This is the constructor to prefer when you can +express the check as a guard. + +### `P.any(predicate)` + +```ts +const P.any: (predicate: (value: unknown) => boolean) => Matcher; +``` + +Wraps a boolean predicate and declares the narrowed type `T` yourself. Use it +only when a type guard is not expressible; prefer `P.when`. + +### `P.shape(shape, refine?)` + +```ts +const P.shape: ( + shape: S, + refine?: (value: S) => value is T, +) => Matcher; +``` + +Matches an object that has every key of `shape`. A shape value that is a +`Matcher` is applied; otherwise the value is compared with `===`. Pass `refine` +to narrow to `T`; without it, the matched type is `S`. + +### Types + +```ts +interface Matcher { + readonly matches: (value: unknown) => value is T; +} + +type Pattern = Matcher; +``` + +Every pattern constructor returns a `Matcher`. `Pattern` is an alias kept +for readability. + +## License + +MIT © 2025 tmu. See [LICENSE](./LICENSE). ## Contributing -For maintainer and contributor docs — the script prefix convention, the feedback-tier system, the rules the tools don't enforce, and the publishing workflow — see [CONTRIBUTING.md](./CONTRIBUTING.md). AI coding agents: your entry point is [AGENTS.md](./AGENTS.md), which points back to CONTRIBUTING.md. - -- Commit signing (GPG). -- Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases. +Contributions are documented in [CONTRIBUTING.md](./CONTRIBUTING.md); the +reasons behind the project's decisions, rejected alternatives, and known issues +live in [development/](./development/README.md). AI coding agents start at +[AGENTS.md](./AGENTS.md). From c7372732bae5738553bfc1ce69a3f80bba6200c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:27:21 +0000 Subject: [PATCH 05/12] :recycle: Move script-header rationale into development/ branch.sh, finish.sh, release.sh, runner-image.sh and release-notes.sh carried multi-paragraph 'how we got here / rejected' essays in comments. They now live in the matching development/ category file, and each script keeps a one-line pointer so the rationale is single-homed and cannot drift. --- scripts/branch.sh | 42 ++++++---------------------------------- scripts/finish.sh | 29 +++++++-------------------- scripts/precompress.ts | 3 +++ scripts/release-notes.sh | 2 ++ scripts/release.sh | 28 ++++++--------------------- scripts/runner-image.sh | 3 +++ 6 files changed, 27 insertions(+), 80 deletions(-) diff --git a/scripts/branch.sh b/scripts/branch.sh index 7c65eee..19d394a 100755 --- a/scripts/branch.sh +++ b/scripts/branch.sh @@ -4,43 +4,13 @@ set -eu # Branch front-door. Run as `npm run create:branch -- /`. # -# How we got here (short): the branching model says every change starts from a -# clean, current `main`, and the type-driven loop only produces trustworthy -# results if the baseline was green *before* the first edit. Both facts were -# prose. Prose rots silently — a rule nobody checks is a suggestion — so the -# precondition became this script: it asserts, then branches, and the branch -# only appears if the assertions passed. Cheap checks run first, `npm run test` -# runs last: the expensive gate is not paid on a tree that was never eligible. +# Asserts the branch preconditions — clean tree, no in-progress operation, +# current `main`, green baseline — and only then creates the branch, so the +# expensive `npm run test` is not paid on a tree that was never eligible. # -# Rejected for the prefix name: `run:` / `perform:` (both mean only "do the -# thing named after them", so every script in the repo would fit under them and -# the taxonomy collapses); `git:` (names the tool, not the lifecycle moment, and -# advertises passthrough aliases); `start:` (describes this 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); and 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. -# -# `create:` was kept because both members really do create something: a branch, -# a release. It was added to both prefix lists in 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 carried in backlog.tasks (since retired into `setup:`). -# There is deliberately no bare `create` aggregator: "run all the workflows" -# describes nothing anyone wants, and `publish:*` already sets the precedent for -# a prefix without one. -# -# Also rejected here: reusing `pubv`'s preflight (release-shaped, third-party, -# and it would make branch start pay a build + pack it has no use for). The -# merge half was originally rejected too ("review the diff yourself" is -# judgment), but it now has its own front door — `create:finish` — which owns -# the merge-side preconditions and the post-merge `verify`, so the start half -# does not have to carry that burden. -# -# Every refusal is non-mutating except the baseline test, which runs on `main` -# after we switch there — so a red `main` restores the branch you started on -# rather than stranding you on it. +# Why the front door exists, the rejected prefix names, and the `create:` +# decision: development/workflow.md § Branching model and § Script prefix +# convention. BASE="main" PREFIXES="feature fix chore" diff --git a/scripts/finish.sh b/scripts/finish.sh index 2ac6b9f..f3e40f0 100755 --- a/scripts/finish.sh +++ b/scripts/finish.sh @@ -4,29 +4,14 @@ set -eu # Feature-finish front door. Run as `npm run create:finish`. # -# Why this exists: `create:branch` opens a unit of work, but the close half -# (`git checkout main && git merge --no-ff `) stayed prose in the -# branching model, so it drifted per contributor and per session. This is the -# mirror image of `create:branch`: it asserts the same preconditions (clean -# tree, no in-progress operation, `main` matching its upstream), merges the -# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with -# `npm run verify`, and only then deletes the branch. +# Mirror image of `create:branch`: asserts the merge-side preconditions, merges +# the current `feature/`/`fix/`/`chore/` branch into `main` with `--no-ff`, +# proves the result with `npm run verify`, and only then deletes the branch. The +# push is owned by `create:release`, so the merge stays local and reviewable. +# On a conflict it aborts and returns to the feature branch. # -# `create:branch`'s comment argued against wrapping the merge as "judgment — -# review the diff yourself". That judgment still lives here, just moved: the -# maintainer reviews the handover *before* invoking this, and the script only -# commits the merge, never the push. The push is owned by `create:release`, so -# the release commit and its tag leave together and a local merge stays -# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff` -# keeps the unit of work visible in `git log`. -# -# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused: -# the tree is clean (checked above) and `main` is not the checked-out branch -# yet, so there is no local state to lose. True divergence (local commits *and* -# upstream commits) is still refused — that needs a human. -# -# On a merge conflict we abort and return to the feature branch, so a failed -# finish never strands you on a half-merged `main`. +# Rationale and the rejected alternatives: development/workflow.md § Branching +# model. BASE="main" PREFIXES="feature fix chore" diff --git a/scripts/precompress.ts b/scripts/precompress.ts index 2327a01..4ff1794 100644 --- a/scripts/precompress.ts +++ b/scripts/precompress.ts @@ -9,6 +9,9 @@ import zlib from "node:zlib"; * matching `Accept-Encoding` and falls back to the original for the rest. * * Usage: node --strip-types scripts/precompress.ts [...] + * + * Why sidecars rather than per-request compression: development/ci.md § Coverage + * serving. */ /** diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh index 64540fa..8c39a24 100755 --- a/scripts/release-notes.sh +++ b/scripts/release-notes.sh @@ -9,6 +9,8 @@ set -eu # `v1.2.3` and `1.2.3` match the `## [1.2.3]` heading. Prints to stdout and # exits non-zero when the tag has no section, so a release can never publish # with an empty body. +# +# Why: development/publishing.md § Release notes are extracted from the changelog. TAG="${1:-}" CHANGELOG="${CHANGELOG:-CHANGELOG.md}" diff --git a/scripts/release.sh b/scripts/release.sh index b8ec2a3..c24092f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -4,29 +4,13 @@ set -eu # Release front-door. Run as `npm run create:release`. # -# How we got here (short): 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. So split by strength: `pubv` (tiny, -# changelog-driven) owns preflight + the interactive major/minor/patch heuristic -# + graduating/committing CHANGELOG.md (no tag, no push); `npm version` syncs -# package.json + the lockfile; `--amend` folds them into pubv's single commit; -# tag AFTER the amend (so the tag is never orphaned) and push. +# Graduates the [Unreleased] changelog notes, bumps package.json + the lockfile, +# and commits then tags the exact SHA that CI publishes. The notes are finalized +# in VS Code before pubv because pubv's bump heuristic reads the [Unreleased] +# body. # -# 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. pubv refuses a dirty tree, so that -# edit is committed as a staging commit and folded back into the single release -# commit below. -# -# Rejected: the conventional-commits family (our history is gitmoji, not -# Conventional; and we want hand-written notes); changesets/rtk (config + a -# heavier version/publish flow that fights our CI-only publish); knope/kacl/ -# bestikk (changelog-only — don't bump package.json; plus 5yr/2yr/brand-new -# maintenance); pubv alone (verified it never writes package.json). We also -# tried `versions` (silverwind) — great Gitea support — but pairing it with a -# hand-rolled promote became a ~180-line script we'd have to maintain, which is -# exactly what this ~30-line version replaces. +# Tooling rationale, the rejected alternatives, and the version source of truth: +# development/publishing.md. CHANGELOG="CHANGELOG.md" BASE="main" diff --git a/scripts/runner-image.sh b/scripts/runner-image.sh index 6205cbd..ccab054 100755 --- a/scripts/runner-image.sh +++ b/scripts/runner-image.sh @@ -8,6 +8,9 @@ set -euo pipefail # pulls the image by name. # # Usage: scripts/runner-image.sh [--push] +# +# Why the image is baked, its two invariants, and the coordinated Node-bump +# steps: development/ci.md. IMAGE_REPO="gitea.e1nsnull.de/tmu/act-ci" From 84d48c6e675fcb0c81a61cfb4fec32db048a0288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:39:58 +0000 Subject: [PATCH 06/12] :memo: Make the rule/rationale split explicit AGENTS.md and CONTRIBUTING.md now state that a decision, its rejected alternatives and its known issues belong in development/.md, that the actionable rule stays in CONTRIBUTING.md, and that both change in the same commit. development/README.md names library.md in the category list and spells out that the sync runs in both directions. --- AGENTS.md | 1 + CONTRIBUTING.md | 5 +++++ development/README.md | 7 ++++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 54a6364..bc94353 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ first-action facts. Do not restate evolving prose here — it will drift. - **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run. - **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. +- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Change both in the same commit. - **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch. ```sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f92ae3..2d26bc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,11 @@ reaches for by default: - **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (why: [development/publishing.md](./development/publishing.md#ci-only-publishing)) +- **A decision or its rationale belongs in `development/`, not here.** This file + holds the actionable rule; `development/.md` holds why, the rejected + alternatives and the known issues. When you change a rule, update its category + file in the same commit and cross-link the two. (why: + [development/README.md](./development/README.md)) ## Branching model diff --git a/development/README.md b/development/README.md index b52a3ce..c731e90 100644 --- a/development/README.md +++ b/development/README.md @@ -76,10 +76,11 @@ of downloading per job. ## Adding to these docs -1. Pick the category file for the area you are changing: `workflow`, `tooling`, - `testing`, `ci`, or `publishing`. +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. + two. The reverse also holds: never change a rule in CONTRIBUTING.md without + updating its rationale here. From fe02317fc8e7a3a07ebcdb811c86089cc49977aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:40:04 +0000 Subject: [PATCH 07/12] :memo: Track code-fence testing and the finish/push tension Adds a Documentation task to validate Markdown code fences against src/, and a Workflow task for the create:finish / create:branch upstream mismatch recorded in development/workflow.md. --- backlog.tasks | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index faed495..2bc147c 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -57,6 +57,10 @@ Documentation: ☐ Add comparison section vs. other TS pattern-matching libs in Readme.md ☐ Write migration guide for users coming from discriminated unions ☐ Create backlog tasks for implementation +☐ Validate code fences in Markdown (start with README.md) — compile the TypeScript examples against `src/` so the docs cannot drift from the API + +Workflow: +☐ Resolve the finish/push tension: `create:finish` leaves `main` ahead of its upstream while `create:branch` refuses until `main` matches upstream — decide whether `finish` should push or `branch` should compare only `BEHIND` (see development/workflow.md) Maintenance: ☐ Serve CI coverage over a tiny self-hosted webserver (replace the zip artifact) @low From 95d73d11b668eec918296bb78ab2621528a5bad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:46:31 +0000 Subject: [PATCH 08/12] :recycle: Single-home the actionable rules and the rationale development/ had restated the actionables that CONTRIBUTING.md owns: the branching step list, the script prefix list, the feedback-tier rule of thumb, the commit convention and the type-driven test loop. Those now live only in CONTRIBUTING.md; development/ keeps the decision blocks and links to the rule. development/README.md, CONTRIBUTING.md and AGENTS.md state the 'write each fact once' principle explicitly. --- AGENTS.md | 2 +- CONTRIBUTING.md | 71 ++++++++++++++++++++++------- development/README.md | 3 ++ development/testing.md | 41 +++++------------ development/workflow.md | 98 ++++++++--------------------------------- 5 files changed, 89 insertions(+), 126 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bc94353..7372e24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ first-action facts. Do not restate evolving prose here — it will drift. - **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet). The server keeps running across that gate with a ~5 min idle timeout and registers no file watchers, so if you change `tsconfig.json` / `package.json` mid-session its diagnostics can be stale — when LSP output disagrees with `check:tsc`, trust `check:tsc` and restart pi (or wait out the idle timeout) before concluding the LSP is wrong. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run. - **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. -- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Change both in the same commit. +- **Document decisions where the next maintainer will look:** rationale, rejected alternatives and known issues go in `development/.md` (see [development/README.md](./development/README.md)); the actionable rule stays in [CONTRIBUTING.md](./CONTRIBUTING.md) and links to it. Write each fact once — never copy the rule into `development/` or the reason into `CONTRIBUTING.md` — and change both in the same commit when a rule changes. - **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch. ```sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d26bc5..c4d86df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,14 +51,27 @@ are where they are: [development/workflow.md § Feedback tiers](./development/wo ## Testing discipline (type-driven) -For this library the types _are_ the feature, so the loop is **type → red → -green → refactor**: write the `expectTypeOf(...)` assertion first, then the -runtime `assert.*`, then the implementation. Every test pairs the two; keep -them together. Type-first is enforced structurally: `npm test` runs -`check:tsc` before the test runner, so a wrong type can never be papered over by -a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach -green honestly — fix the types, never suppress the checks you can't make pass. -Full rationale: [development/testing.md](./development/testing.md). +For this library the types _are_ the feature, so development is **type-driven**: +the compile-time expectation is written before the runtime assertion, and both +before the implementation. The loop is **type → red → green → refactor**: + +1. **Type** — write the compile-time expectation first + (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on + the _type_. The type error is the spec you want to hit before the runtime + logic exists. +2. **Red** — add the matching runtime assertion (`assert.*`) so + `npm run test:unit` now fails on behavior. +3. **Green** — implement in `src/*.ts` until both the type check and the test + pass. +4. **Refactor** — with the type system and the tests as the safety net, then + `npm run verify` as the definition-of-done gate. + +Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together. +Type-first is enforced structurally: `npm test` runs `check:tsc` before the +test runner, so a wrong type can never be papered over by a passing assertion. +Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the +types, never suppress the checks you can't make pass. Full rationale: +[development/testing.md](./development/testing.md). ## Code style and formatting @@ -83,13 +96,39 @@ Gitmoji subject, imperative mood, 50/72 wrapping. The template is ## Script prefix convention -A new `npm run` script must reuse an existing prefix: `create:` / `check:` / -`fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`. If none fits, -that's a signal the script doesn't belong in the pipeline — not a reason to -invent a new prefix. If it genuinely does belong, add the prefix to the list -here in the same commit as its first member; an undocumented prefix becomes -invisible and quietly accrues members. Full convention and why `create:` exists: -[development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention). +Script names in `package.json` use a prefix that signals _when_ the script is +intended to run. A `:` script is implicitly aggregated by a +`` script (if one exists) and run by the corresponding lefthook hook or +CI step. Pick the prefix that matches the script's lifecycle: + +- `create:*` — front doors of the repo's own workflow; these mutate git state + rather than the source. `create:branch` opens a unit of work, `create:finish` + closes the branch half, `create:release` closes the release half + (maintainer-only). No bare `create` aggregator on purpose. +- `check:*` — read-only verification; never modifies files. Aggregated by + `npm run check`. +- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by + `npm run fix`; the diff is the review surface. +- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + + unit tests); `test:unit` skips the typecheck for fast local iteration; + `test:ci` adds c8 coverage. +- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by + `watch`. +- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project + and/or network-bound, so never a correctness gate. Aggregated by + `npm run maintain`. +- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than + the source, so it needs a fresh build. +- `setup:*` — one-time configuration of a fresh clone; mutates the local + environment rather than the repo source, so it is never part of a hook or CI + step. Aggregated by `npm run setup`, run once after cloning. + +A new script must reuse an existing prefix. If none fits, that's a signal the +script doesn't belong in the pipeline — not a reason to invent a new prefix. If +it genuinely does belong, add the prefix to this list in the same commit as its +first member; an undocumented prefix becomes invisible and quietly accrues +members. Why `create:` exists, the rejected names, and the design of the bare +scripts: [development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention). ## Rules the tools don't enforce @@ -150,6 +189,8 @@ request workflow on Gitea yet. merge stays local and reviewable. - CI runs on every push to `main` — see [Feedback tiers](#feedback-tiers) and [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml). +- **Releases are NOT triggered by pushes.** Only the maintainer triggers a + release; see [Publishing](#publishing). Full rationale, including the front-door decisions and a known issue about `main` being ahead of its upstream between a merge and the next push: diff --git a/development/README.md b/development/README.md index c731e90..fc6fcca 100644 --- a/development/README.md +++ b/development/README.md @@ -10,6 +10,9 @@ 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. 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 diff --git a/development/testing.md b/development/testing.md index 0b11b25..2eb4e78 100644 --- a/development/testing.md +++ b/development/testing.md @@ -8,26 +8,9 @@ the way it is. ## Type-driven development -New behavior follows **type-driven development** (in Edwin Brady's sense): -_treat the type as the plan for a program, and use the compiler and type checker -as your assistant, guiding you to a complete program that satisfies the type_ -([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the -`expectTypeOf` assertion, written first. The loop is **type → red → green → -refactor**: - -1. **Type** — write the compile-time expectation first - (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on - the _type_. The type error is the spec you want to hit before the runtime - logic exists. -2. **Red** — add the matching runtime assertion (`assert.*`) so - `npm run test:unit` now fails on behavior. -3. **Green** — implement in `src/*.ts` until both the type check and the test - pass. -4. **Refactor** — with the type system and the tests as the safety net, then - `npm run verify` as the definition-of-done gate. - -This is why every test in the suite pairs an `expectTypeOf(...)` with an -`assert.*` — keep them together. +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. #### Decision (2026-09) @@ -56,18 +39,14 @@ Per [AGENTS.md § Never do](../AGENTS.md#never-do), reach green honestly — fix types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass. -## Test tiers +The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are +listed 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 +[tooling.md](./tooling.md#source-imports-use-ts-extensions)). -- `npm run test:unit` — the test runner alone, for the fast local loop. -- `npm test` — `check:tsc` + the unit suite; this is what pre-push and the - baseline check run. -- `npm run test:ci` — adds c8 coverage; used by CI. `c8` uses V8 coverage, so - the `--strip-types` source is instrumented without a build step. - -The runner is `node --test --strip-types "src/**/*.test.ts"`. It relies on the -`.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)). - -#### Known issue +## Known issues - The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so `src/index.test.ts` carries a file-level `oxlint-disable diff --git a/development/workflow.md b/development/workflow.md index 7bba966..0237dd5 100644 --- a/development/workflow.md +++ b/development/workflow.md @@ -13,28 +13,9 @@ request workflow on Gitea yet: collaborative review through the Gitea UI is not in place, so Gitea is the lab. When something is tested and ready for production it will be promoted to GitHub. -- **Base branch:** `main` -- **Branch naming:** `feature/` / `fix/` / `chore/` -- **Starting work:** `npm run create:branch -- /`. It refuses, - without changing anything, unless the working tree is clean (untracked files - included), no merge/rebase/cherry-pick is in progress, `main` matches its - upstream, and `npm run test` is green on `main` — so a later failure is always - attributable to your edits. The prefix is still _your_ call, inferred from the - task; the script validates it rather than guessing it. -- **Merging:** `npm run create:finish` (on the branch). It asserts the same - clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale - `main` (a true divergence is refused), merges the branch `--no-ff`, runs - `npm run verify`, and deletes the branch only after the merge is green. The - push is deliberately left to `create:release`, so the merge stays local and - reviewable — read the diff yourself before finishing. -- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is - the authoritative gate. The one exception: a push headed by a release commit - (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because - `create:release` pushes the tag for that exact commit right after and the tag - run is the authoritative one (see `release-gate` in - [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml)). -- **Releases are NOT triggered by pushes.** Only the maintainer triggers a - release (see [publishing.md](./publishing.md)). +The contributor-facing steps are in +[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model); what +follows is why the front doors exist and what was rejected. #### Decision (2026-09) @@ -79,51 +60,18 @@ prose plus hand-written `git` commands. ## Script prefix convention -Script names in `package.json` use a prefix that signals _when_ the script is -intended to run. A `:` script is implicitly aggregated by a -`` script (if one exists) and run by the corresponding lefthook hook or -CI step. Picking the right prefix documents the script's intended lifecycle: +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. -- `create:*` — front doors of the repo's own workflow; these mutate git state - rather than the source. `create:branch` opens a unit of work, `create:finish` - closes the branch half, `create:release` closes the release half - (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` - for the precedent. -- `check:*` — read-only verification; never modifies files. Aggregated by - `npm run check`. -- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by - `npm run fix`; the diff is the review surface. -- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + - unit tests); `test:unit` skips the typecheck for fast local iteration; - `test:ci` adds c8 coverage. -- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by - `watch`; currently a single child (`watch:test`), and a future - `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella. -- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project - and/or network-bound, so never a correctness gate. Aggregated by - `npm run maintain`. -- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than - the source, so it needs a fresh build. -- `setup:*` — one-time configuration of a fresh clone; mutates the local - environment (git config, editor settings) rather than the repo source, so it - is never part of a hook or CI step. Aggregated by `npm run setup` (the - umbrella), run once after cloning. - -A new script should pick the prefix that matches its lifecycle, not invent a new -one. If no existing prefix fits, that's a signal the script doesn't belong in -the standard pipeline. When it genuinely does belong, a new prefix is allowed — -but it enters both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in the same -commit as its first member, otherwise the rule "reuse an existing prefix" -silently develops an exception. - -Separately, some top-level scripts are **bare** (no prefix): the entry points -that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family -(`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that -composes across tiers: `verify` — composing `check` + `test:unit` into one -whole-project correctness gate (it deliberately uses `test:unit` rather than -`test` because `check` already runs `check:tsc`, so the type checker runs -exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts -are what those tiers are made of. +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. #### Decision (2026-09) @@ -155,11 +103,9 @@ aggregator. ## Feedback tiers -The tools are organized into a feedback ladder. Each tier catches different -things at different costs; the rule of thumb is "earlier tiers fire more often, -faster tiers catch less, slower tiers are more thorough". The tier table itself -lives in [CONTRIBUTING.md](../CONTRIBUTING.md#feedback-tiers); this section -explains why the split is where it is. +The tier table and the rules for invoking it are in +[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this +section explains why the split is where it is. #### Decision (2026-09) @@ -193,16 +139,10 @@ into pre-push and `verify`; and slow or network-bound scans into `maintain`. tier; `verify` is run by hand because the push is where the whole project is already checked. -Before pushing, run `npm run verify` — the one-shot correctness gate. Run -`npm run maintain` only on a maintenance / update-deps branch. - ## Commit messages -Gitmoji subject, imperative mood, 50/72 wrapping. The template is -[commit-message-template](../commit-message-template); run -`npm run setup:git-commit-message` once after cloning to register it as git's -`commit.template` (or `npm run setup` to run every one-time clone step). - +The convention is in +[CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages). Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The From 7ea84b66fabd22b20c6c681b4ef793cdc14b5a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:47:01 +0000 Subject: [PATCH 09/12] :recycle: Drop the last duplicated prose from development/ The GitHub Flow description and the type-first enforcement sentence still appeared in both CONTRIBUTING.md and development/. development/ now carries only the context and rationale that is not in CONTRIBUTING.md. --- development/testing.md | 8 -------- development/workflow.md | 13 +++++-------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/development/testing.md b/development/testing.md index 2eb4e78..f0b6e71 100644 --- a/development/testing.md +++ b/development/testing.md @@ -31,14 +31,6 @@ 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`). -## Enforcement - -Type-first is also enforced structurally: `npm test` runs `check:tsc` before the -test runner, so a wrong type can never be papered over by a passing assertion. -Per [AGENTS.md § Never do](../AGENTS.md#never-do), reach green honestly — fix the -types so both the type check and the runtime assertion pass, never suppress the -ones you can't make pass. - The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are listed in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). diff --git a/development/workflow.md b/development/workflow.md index 0237dd5..4e23629 100644 --- a/development/workflow.md +++ b/development/workflow.md @@ -7,15 +7,12 @@ are shaped the way they are. ## Branching model -**GitHub Flow (single-developer).** Every change — feature, fix, refactor — -branches off `main` and is merged back via a local commit. There is no pull -request workflow on Gitea yet: collaborative review through the Gitea UI is not -in place, so Gitea is the lab. When something is tested and ready for -production it will be promoted to GitHub. - The contributor-facing steps are in -[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model); what -follows is why the front doors exist and what was rejected. +[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. #### Decision (2026-09) From 74c39e13460efa379547e51827702238d8747b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 13:56:49 +0000 Subject: [PATCH 10/12] :recycle: 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. --- development/README.md | 71 +++++++--------- development/ci.md | 104 ++++++++++------------ development/library.md | 86 +++++++++---------- development/publishing.md | 111 ++++++++++++------------ development/testing.md | 28 +++--- development/tooling.md | 175 ++++++++++++++++++-------------------- development/workflow.md | 175 +++++++++++++++++--------------------- 7 files changed, 341 insertions(+), 409 deletions(-) diff --git a/development/README.md b/development/README.md index fc6fcca..dd827ce 100644 --- a/development/README.md +++ b/development/README.md @@ -1,23 +1,18 @@ # Development documentation -These files explain **why** this project works the way it does: the decisions -behind it, what was rejected, and the shortcomings and known issues we carry. -They are written for maintainers and contributors of the project itself. +Why this project works the way it does: the decisions, what was rejected, and +the shortcomings and known issues we carry. Written for maintainers and +contributors. -The actionable rules — how to set up, run, test, and submit — live in -[CONTRIBUTING.md](../CONTRIBUTING.md). This folder is the "why" behind those -rules: read the relevant file here before changing an area, and update it when -a decision changes, rather than leaving an obsolete reason behind. A rule and -its reason drift apart when they live in one place and are maintained in two; -keeping the rule in CONTRIBUTING.md and the reason here keeps each single-homed. -**Each fact is written once**: the actionable rule in CONTRIBUTING.md, the -reason here. Neither restates the other — when the same fact would be useful in -both, one links to the other instead of copying it. +The actionable rules — setup, running, testing, submitting — live in +[CONTRIBUTING.md](../CONTRIBUTING.md). **Each fact is written once**: the rule +there, the reason here; neither restates the other, and where a fact is useful +in both they link. Read the relevant file before changing an area, and when a +rule changes update its rationale here in the same commit. -User-facing documentation lives in [README.md](../README.md). A `docs/` folder -is deliberately not used yet: the convention is that `docs/` is the future -user documentation site, and deploying that site is out of scope. When it -exists, these files stay here — they are not user documentation. +User-facing documentation is [README.md](../README.md). `docs/` is deliberately +unused: that name is reserved for the future user documentation site, and +deploying it is out of scope. These files are not part of that site. ## Layout @@ -32,15 +27,13 @@ One file per category: | [ci.md](./ci.md) | CI pipeline, runner image, coverage serving | | [publishing.md](./publishing.md) | Release and npm publishing | -A category that outgrows one file becomes a folder with an index, but we start -with one file per category because each area is small enough to hold in mind at -once. When a category splits, the links in CONTRIBUTING.md and README.md keep -pointing at the category, not at a single decision. +We start with one file per category so each area stays small enough to hold in +mind; a category that outgrows it becomes a folder with an index, and the links +in CONTRIBUTING.md and README.md point at the category, not a single decision. ## Decision blocks -Every non-obvious choice is recorded as a block inside the relevant category -file, using this shape: +Record every non-obvious choice as a block in the relevant category file: ```md ## Runner image @@ -65,25 +58,21 @@ of downloading per job. - recover with `docker rmi ` ``` -- The date is the month the decision was made, not the date the file was last - edited. It is the anchor a reader uses to tell "this is current" from "this - was current once". -- `Rejected` is what makes the record worth keeping: it stops the project from - re-litigating the same alternatives. An empty `Rejected` usually means the - alternatives were never written down. -- `Known issue` is where shortcomings live. If a caveat is not tied to one - decision, put it under a `## Known issues` section at the end of the file. -- A superseded decision is **replaced in place**, not archived in a second - file — the file always describes the current world, and git history is the - archive. +- The date is the month the decision was made, not when the file was edited — + the anchor for "current" versus "was current once". +- `Rejected` stops the project re-litigating the same alternatives; an empty one + usually means they were never written down. +- `Known issue` is where shortcomings live. A caveat not tied to one decision + goes under a `## Known issues` section at the end of the file. +- Replace a superseded decision in place rather than archiving it; git history + is the archive. ## Adding to these docs -1. Pick the category file for the area you are changing: `library`, `workflow`, - `tooling`, `testing`, `ci`, or `publishing`. -2. Add or update a decision block. Keep the existing text unless the decision - actually changed. -3. If the change alters an actionable rule, update - [CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link the - two. The reverse also holds: never change a rule in CONTRIBUTING.md without - updating its rationale here. +1. Pick the category: `library`, `workflow`, `tooling`, `testing`, `ci`, + `publishing`. +2. Add or update a decision block; keep existing text unless the decision + changed. +3. If an actionable rule changes, update + [CONTRIBUTING.md](../CONTRIBUTING.md) in the same commit and cross-link. + Never change a rule there without updating its rationale here. diff --git a/development/ci.md b/development/ci.md index 796825e..cc373df 100644 --- a/development/ci.md +++ b/development/ci.md @@ -1,70 +1,63 @@ # CI -The pipeline definition is [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml); -that file, not this prose, is the source of truth for the job graph. This file -records why the pipeline and its runner image are shaped the way they are. +[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) is the source of truth for +the job graph; this file records why it is shaped the way it is. ## Pipeline - **`build`** (push to `main` / tag) — build + correctness + packaging. -- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; it reports - and never fails the build. +- **`maintain`** (push to `main`, non-blocking) — `npm run maintain`; reports, + never fails the build. - **`publish`** (tag) — packaging checks + `publish:publint` / `publish:attw`, - then the Gitea release page and `npm publish`. See - [publishing.md](./publishing.md). -- **`release-gate`** — recognizes a `:rocket: Release x.y.z` commit and skips the - `build`/`maintain` jobs, because `create:release` pushes the tag for the exact - same commit right after and the tag run is authoritative. It uses no Node and - stays on the runner's default image. + then the Gitea release page and `npm publish` (see + [publishing.md](./publishing.md)). +- **`release-gate`** — on a `:rocket: Release x.y.z` commit it skips + `build`/`maintain`, because `create:release` pushes the tag for the same commit + right after and the tag run is authoritative. It uses no Node and stays on the + default image. ## Runner image #### Decision (2026-09) -The `build` / `maintain` / `publish` jobs run in -`gitea.e1nsnull.de/tmu/act-ci:` -([docker/Dockerfile](../docker/Dockerfile)) — the runner's default act image -with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout -`actions/setup-node` probes before downloading. +`build` / `maintain` / `publish` run in `gitea.e1nsnull.de/tmu/act-ci:` +([docker/Dockerfile](../docker/Dockerfile)): the default act image with Node +overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes. #### Why - No job pays the ~50 MB Node fetch, because the probe hits the baked entry. -- The image tag must equal the exact version pinned in - [.node-version](../.node-version), and the image is rebuilt only as part of a - Node bump — there is no other trigger. -- Building requires a docker daemon and registry credentials, so it belongs to - no feedback tier. This is why it is deliberately **not** an `npm run` script: - per the [script prefix convention](./workflow.md#script-prefix-convention), no - existing prefix fits and that is the signal. +- The tag must equal the exact [.node-version](../.node-version) pin, and the + image is rebuilt only as part of a Node bump — there is no other trigger. +- Building needs a docker daemon and registry credentials, so it belongs to no + feedback tier. That is why it is **not** an `npm run` script: no + [prefix](./workflow.md#script-prefix-convention) fits, and that is the signal. #### Rejected -- Downloading Node in every job: the ~50 MB fetch per job was the original - problem. -- Gitea Pages / Codecov for coverage: neither was confirmed available or wanted +- Downloading Node in every job — the ~50 MB fetch was the original problem. +- Gitea Pages / Codecov for coverage — neither was confirmed available or wanted (see [Coverage serving](#coverage-serving)). ## Bumping Node Bumping Node is one coordinated change, committed as a unit: -1. Edit [.node-version](../.node-version) to the new exact `x.y.z` — floats like - `26` resolve to the latest patch at runtime and silently bust the baked - entry; [scripts/runner-image.sh](../scripts/runner-image.sh) refuses them. +1. Edit [.node-version](../.node-version) to the exact `x.y.z` — floats like `26` + resolve to the latest patch at runtime and bust the baked entry, so + [scripts/runner-image.sh](../scripts/runner-image.sh) refuses them. 2. `docker login gitea.e1nsnull.de` (user + package/access token), then - `./scripts/runner-image.sh --push` — it reads the version from `.node-version` - and builds/pushes `:`. + `./scripts/runner-image.sh --push`, which reads the version and pushes + `:`. 3. Repoint the three `container.image` tags in - [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to the same version. + [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml) to that version. Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download. ## Image invariants -Two invariants the image must satisfy for the `setup-node` probe to hit, both -easy to break: +For the `setup-node` probe to hit, two things must hold — both easy to break: ### The `x64.complete` marker @@ -75,10 +68,9 @@ Bake a `/.complete` marker next to the Node directory. #### Why - `actions/tool-cache` accepts a cached tool only when - `/.complete` exists next to the directory (`tc.find()` checks - it). A plausible-looking `node//x64/` alone is ignored and the - download happens anyway. See the comment in - [docker/Dockerfile](../docker/Dockerfile). + `/.complete` exists beside it (`tc.find()` checks). A bare + `node//x64/` is ignored and the download happens anyway. See the + comment in [docker/Dockerfile](../docker/Dockerfile). ### Tag freshness, with force-pull deliberately off @@ -89,9 +81,8 @@ Leave `act_runner`'s `force_pull` disabled. #### Why - The tag encodes only the Node version, and the image is rebuilt only when that - version changes — so the normal flow always yields a new tag and the runner - pulls it. -- Forcing a pull would re-pull the image on every job for no benefit. + changes — so the normal flow always yields a new tag and the runner pulls it. +- Forcing a pull re-pulls the image on every job for no benefit. #### Rejected @@ -101,10 +92,10 @@ Leave `act_runner`'s `force_pull` disabled. #### Known issue - A `Dockerfile`-only change (like the marker above) re-pushed under an - unchanged tag is invisible to the runner — it keeps the old image while the - registry shows the new digest. If that ever matters, remove the stale tag on - the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not - reach for force-pull. + unchanged tag is invisible to the runner, which keeps the old image while the + registry shows the new digest. Remove the stale tag on the runner host + (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not reach for + force-pull. ## Coverage serving @@ -115,9 +106,8 @@ CI. #### Why -- The webserver just exposes the shared directory; the Gitea docker setup reuses - the existing reverse proxy. There is no upload artifact and no external - service. +- The webserver exposes the shared directory and the Gitea docker setup reuses + the existing reverse proxy — no upload artifact, no external service. - Coverage is written to a shared volume keyed by project and tag (for example `/docs/tiny-pattern-ts//`). @@ -127,20 +117,20 @@ CI. #### Known issue -- Coverage is currently served for tag pushes only. Serving it for non-tag - pushes (for example `main/coverage`) is tracked separately. +- Coverage is served for tag pushes only; non-tag pushes (for example + `main/coverage`) are tracked separately. [scripts/precompress.ts](../scripts/precompress.ts) emits `.br` / `.gz` / `.zst` -sidecars next to every text asset under the served directories. The Gitea pages -service (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the -sidecar matching `Accept-Encoding` and falls back to the original for the rest. +sidecars next to text assets. The Gitea pages service (`static-web-server` with +`SERVER_COMPRESSION_STATIC=true`) serves the sidecar matching `Accept-Encoding` +and falls back to the original. #### Decision (2026-09) -Precompress text assets into sidecars rather than compressing on each request. +Precompress into sidecars rather than per request. #### Why -- The files are static and change only on deploy, so the work is paid once. -- Images, fonts and archives are already compressed; a sidecar would only make - them bigger, which is why only text extensions are emitted. +- The assets are static and change only on deploy, so the work is paid once. +- Images, fonts and archives are already compressed; a sidecar would only grow + them, so only text extensions are emitted. diff --git a/development/library.md b/development/library.md index 6407b89..a0818c3 100644 --- a/development/library.md +++ b/development/library.md @@ -1,116 +1,106 @@ # Library design -The type-level design of the public API, and the limitations it carries. The -user-facing reference is [README § API](../README.md#api); this file records why -the API is shaped the way it is. +The type-level design of the public API and the limitations it carries. The +user-facing reference is [README § API](../README.md#api). ## A type guard is the single primitive #### Decision (2026-09) Every pattern constructor returns a `Matcher` whose only member is -`matches: (value: unknown) => value is T`. `Pattern` is an alias for -`Matcher`. +`matches: (value: unknown) => value is T`; `Pattern` is an alias. #### Why - A type guard is the one TypeScript construct that both narrows in an `if` and - composes into a chain, so the whole library can be built from it without a DSL - or transpiler. -- Because `matches` narrows, `match(value).with(pattern, handler)` can give the - handler the right type with no cast and no runtime type tag. -- Keeping `Pattern` as an alias means a caller can name either; the type is - identical. + composes into a chain, so the library needs no DSL and no transpiler. +- Because `matches` narrows, `match(value).with(pattern, handler)` types the + handler with no cast and no runtime tag. +- `Pattern` is an alias, so either name is the same type. ## The builder is immutable #### Decision (2026-09) -`match(value)` returns a builder whose `.with` returns a _new_ builder rather -than mutating the current one. +`.with` returns a new builder rather than mutating the current one. #### Why - A partially built chain can be stored and reused without one call site affecting another. -- `.with` widens the result type `R` to `R | V`; a value that changes type in - place cannot be represented soundly. A new value per `.with` is what makes the - type-level accumulation work. +- `.with` widens the result type to `R | V`, which cannot be represented by a + value that changes type in place. ## `exhaustive()` is a runtime check #### Decision (2026-09) -`.exhaustive()` throws at runtime when no case matched. It does not statically -prove that every member of the input union has a case. +`.exhaustive()` throws when no case matched; it does not statically prove that +every member of the input union has a case. #### Why -- TypeScript cannot require a fluent call chain to cover every union member - without a compiler plugin or a builder whose type tracks an uncovered-member - set. That machinery is out of proportion to the library's size. -- The union-of-return-types (`R`) is still type-safe; what is not guaranteed is - that the chain is complete. +- Proving coverage through a fluent chain would need a compiler plugin or a + builder that tracks an uncovered-member set — out of proportion to this + library's size. +- The union of handler return types is still type-safe; only the chain's + completeness is unchecked. #### Known issue -- A missing case is a runtime `Error`, not a compile error. A caller who wants - compile-time safety must either add a `.otherwise(...)` (which never throws) or - prove coverage themselves. Making the builder track uncovered members is a - possible future change; it is not in scope now. +- A missing case is a runtime `Error`, not a compile error. A caller wants + either an `.otherwise(...)` (which never throws) or to prove coverage + themselves. Tracking uncovered members is a possible future change. ## `P.type` takes the type as a parameter #### Decision (2026-09) -`P.type(name)` requires the caller to supply `T` explicitly; it is not -inferred from the `typeof` string. +`P.type(name)` requires the caller to supply `T`; it is not inferred from the +`typeof` string. #### Why -- A runtime string cannot carry a TypeScript type. The caller pairs the two, and +- A runtime string cannot carry a TypeScript type; the caller pairs the two, and the compiler only checks that `T` is used consistently afterwards. #### Known issue -- The type parameter and the runtime name can disagree (`P.type("string")` - compiles), because nothing ties `T` to `name`. `P.when` with a real type guard - avoids the pairing entirely, so prefer it. A future revision could map the name - to a type with a conditional type; that is not in scope now. +- `T` and the runtime `name` can disagree (`P.type("string")` compiles), + because nothing ties them together. Prefer `P.when` with a real type guard. A + name-to-type conditional mapping is a possible future change. ## `P.shape` narrows only through `refine` #### Decision (2026-09) -`P.shape(shape, refine?)` returns `Matcher`, defaulting to -`Matcher` when no `refine` is given. +`P.shape(shape, refine?)` returns `Matcher`, defaulting to `Matcher` +without a `refine`. #### Why -- The shape object's values may be matchers, so the shape's literal type is not - the matched type: `S` describes the check, not the value. A `refine` type guard - is the explicit point where the value is narrowed to `T`. -- Checking keys with `in` (rather than requiring exact keys) lets a shape match a - wider object, which is how discriminated unions are handled. +- The shape's values may be matchers, so `S` describes the check, not the value; + the `refine` guard is the explicit point where the value narrows to `T`. +- Keys are checked with `in` rather than requiring an exact match, so a shape can + match a wider object — which is how discriminated unions are handled. #### Known issue -- Without `refine`, a discriminated-union case does not narrow, so `P.shape` - on its own is easy to misuse. The README shows the `refine` form; prefer - `P.when` when a guard is already available. +- Without `refine` a discriminated-union case does not narrow, so `P.shape` + alone is easy to misuse. Prefer `P.when` when a guard is already available. ## `P.any` exists for non-guard predicates #### Decision (2026-09) -`P.any(predicate)` accepts a boolean predicate and a declared `T`. +`P.any(predicate)` takes a boolean predicate and a declared `T`. #### Why -- Some checks are cheap to write as a boolean but awkward as a type guard (for - example an `every` over an array). `P.any` is the escape hatch for those. +- Some checks are cheap as a boolean but awkward as a type guard (for example an + `every` over an array); `P.any` is the escape hatch. #### Known issue -- Like `P.type`, the declared `T` is not proven by the predicate. Prefer +- As with `P.type`, the declared `T` is not proven by the predicate. Prefer `P.when` whenever the check can be written as a guard. diff --git a/development/publishing.md b/development/publishing.md index 324f509..70d8d6d 100644 --- a/development/publishing.md +++ b/development/publishing.md @@ -1,31 +1,28 @@ # Publishing -Publishing is CI-only by policy. Local `npm publish` is not supported. The -maintainer triggers releases from `main`; the mechanics live in +Publishing is CI-only: local `npm publish` is not supported, and the maintainer +triggers releases from `main`. The mechanics are in [scripts/release.sh](../scripts/release.sh) and -[scripts/release-notes.sh](../scripts/release-notes.sh), and the job graph lives -in [.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml). +[scripts/release-notes.sh](../scripts/release-notes.sh); the job graph is +[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml). ## Release steps 1. All intended changes are merged to `main` and passing CI. 2. The maintainer runs `npm run create:release`. VS Code opens [CHANGELOG.md](../CHANGELOG.md) to finalize the `[Unreleased]` notes; because - pubv refuses a dirty tree, any edit is committed first (then folded into the - release commit), and pubv's interactive prompt suggests a version from those - notes — the maintainer confirms or edits it. -3. `scripts/release.sh` creates a single release commit (graduated changelog + - `package.json` bump, amended into one commit), tags it, and pushes everything - to Gitea. -4. CI fires on both pushes: the `publish` job runs on the tag (`build` + - publish-tier checks + release page + `npm publish`), while the branch run's - `release-gate` job recognizes the release commit and skips - `build`/`maintain` — the tag verifies the identical SHA, so no work is - duplicated. The publish-tier checks must pass before the artifact is - published. The `publish` job also creates the Gitea release page from the - matching Keep-a-Changelog section; it runs _before_ `npm publish` so a broken - page fails CI without consuming a version, and `npm publish` stays the last - step. + pubv refuses a dirty tree, the edit is committed first (then folded into the + release commit), and pubv suggests a version from those notes to confirm or + edit. +3. `scripts/release.sh` creates one release commit (graduated changelog + + `package.json` bump, amended together), tags it, and pushes. +4. CI fires on both pushes. `publish` runs on the tag (build + publish checks + + release page + `npm publish`), while `release-gate` recognizes the release + commit and skips `build`/`maintain`: the tag verifies the identical SHA, so no + work is duplicated. The publish checks pass before the artifact is published, + and the release page is created from the matching Keep-a-Changelog section + _before_ `npm publish`, so a broken page fails CI without consuming a version + and `npm publish` stays the last step. ## CI-only publishing @@ -39,8 +36,8 @@ Releases are cut by CI from `main`; there is no local `npm publish` and no - The tag is the artifact marker: CI verifies the exact commit it points at, so a local publish could ship something the tag does not describe. - `publish:publint` / `publish:attw` validate the _publishable artifact_, which - needs a fresh build; they are not source-correctness checks, so they do not - belong in `check`. + needs a fresh build; they are not source-correctness checks and do not belong + in `check`. ## The release commit is assembled from two tools @@ -53,32 +50,31 @@ release commit. #### Why - We want hand-written Keep-a-Changelog notes, an `[Unreleased]` -> - `## [x.y.z] - DATE` graduation, and a tag that marks the exact commit on - `main` that gets published. -- No single tool did both the `[Unreleased]` graduation _and_ the - `package.json` bump. Splitting by strength: `pubv` (tiny, changelog-driven) - owns preflight, the interactive major/minor/patch heuristic, and graduating and - committing `CHANGELOG.md` (no tag, no push); `npm version` syncs - `package.json` + the lockfile; `--amend` folds them into pubv's single commit; - the tag is created _after_ the amend so it is never orphaned. -- The notes are finalized in VS Code _before_ pubv: the `[Unreleased]` body is - what pubv's bump heuristic reads, so editing afterwards would inform the - changelog only, not the version choice. The staging commit that makes pubv - accept the tree is folded back into the single release commit. + `## [x.y.z] - DATE` graduation, and a tag on the exact commit that gets + published — and no single tool did both the graduation and the `package.json` + bump. +- Split by strength: `pubv` (tiny, changelog-driven) owns preflight, the + interactive major/minor/patch heuristic, and graduating and committing + `CHANGELOG.md` (no tag, no push); `npm version` syncs `package.json` + the + lockfile; `--amend` folds them into one commit; the tag is created _after_ the + amend so it is never orphaned. +- The notes are finalized _before_ pubv because its bump heuristic reads the + `[Unreleased]` body — editing afterwards would inform the changelog only, not + the version. The staging commit that satisfies pubv's clean-tree check is + folded back into the single release commit. #### Rejected - The conventional-commits family: the history is gitmoji, not Conventional, and - we want hand-written notes (see + the notes are hand-written (see [workflow.md § Commit messages](./workflow.md#commit-messages)). -- `changesets` / `rtk`: config plus a heavier version/publish flow that fights - the CI-only publish. -- `knope` / `kacl` / `bestikk`: changelog-only — they don't bump `package.json` - — and they carry 5-year / 2-year / brand-new maintenance. -- `pubv` alone: verified it never writes `package.json`. -- `versions` (silverwind): great Gitea support, but pairing it with a hand-rolled - promote became a ~180-line script to maintain, which is exactly what this - ~30-line version replaces. +- `changesets` / `rtk`: config plus a heavier flow that fights the CI-only + publish. +- `knope` / `kacl` / `bestikk`: changelog-only (no `package.json` bump) and + 5-year / 2-year / brand-new maintenance. +- `pubv` alone: it never writes `package.json`. +- `versions` (silverwind): good Gitea support, but pairing it with a hand-rolled + promote became a ~180-line script, which this ~30-line version replaces. ## The version has one source of truth @@ -86,38 +82,37 @@ release commit. The version is derived from the graduated `## [x.y.z]` heading in [CHANGELOG.md](../CHANGELOG.md) and written to `package.json` + -`package-lock.json` by `npm version`. The README carries no version line and -does not link `package.json`. +`package-lock.json` by `npm version`. The README has no version line and does +not link `package.json`. #### Why - `release.sh` reads the version from the changelog, so the changelog is the - input and `package.json` is the derived copy — one direction, no drift. + input and `package.json` the derived copy — one direction, no drift. - npm and Gitea render the version from package metadata, so a README copy would be a third place to update for no reader benefit, and a link would only move - the lookup without removing the copy. + the lookup, not remove the copy. #### Rejected -- A `## Version` line in the README: it would make `release.sh` responsible for - a third file. +- A `## Version` line in the README: it makes `release.sh` responsible for a + third file. - Linking `package.json` from the README: it invites a hand-maintained duplicate - that the link does not keep in sync. + the link does not keep in sync. ## Release notes are extracted from the changelog #### Decision (2026-09) `scripts/release-notes.sh ` prints the Keep-a-Changelog section for the tag -and exits non-zero when the section is missing. +and exits non-zero when it is missing. #### Why - CI reuses the release body from the same file that drove the version, so the page and the changelog cannot disagree. -- Failing on a missing section means a release can never publish with an empty - body. A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match the - `## [1.2.3]` heading. +- Failing on a missing section means a release can never publish an empty body. + A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`. ## Token gates @@ -129,11 +124,11 @@ The Gitea release page uses the run's automatic token (`github.token`). #### Why -- The automatic token only needs `contents: write`, so no secret gate is needed - for the release page. -- `secrets` is not an allowed context in a step `if`, so `NPM_TOKEN` must be - lifted into job-level `env`; an unset secret then skips the publish instead of - attempting an unauthenticated one. +- The automatic token needs only `contents: write`, so the release page needs no + secret gate. +- `secrets` is not allowed in a step `if`, so `NPM_TOKEN` must be lifted into + job-level `env`; an unset secret then skips the publish instead of attempting + an unauthenticated one. - A tag is all-or-nothing: without the `always()` guard a skipped or failed npm half would leave the job silently green. The guard turns it red. diff --git a/development/testing.md b/development/testing.md index f0b6e71..d4ef8be 100644 --- a/development/testing.md +++ b/development/testing.md @@ -1,8 +1,7 @@ # Testing -For this library the types _are_ the feature — narrowing, `exhaustive()` -returns, the `Matcher` contract — so a runtime-only test loop would verify -the wrong thing. The contributor-facing commands are in +For this library the types _are_ the feature, so a runtime-only test loop would +verify the wrong thing. The commands are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records why the loop is shaped the way it is. @@ -10,17 +9,17 @@ the way it is. The rules — the loop and the pairing rule — are in [CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven). -What follows is why the loop is type-driven and what was rejected. +What follows is why and what was rejected. #### Decision (2026-09) -Test-driven development is _type_-driven here: the compile-time expectation is -written before the runtime assertion, and both before the implementation. +The compile-time expectation is written before the runtime assertion, and both +before the implementation. #### Why -- A runtime-only test can pass while the type is wrong, and a type-level library - would then ship a broken feature that its tests bless. +- A runtime-only test can pass while the type is wrong, so a type-level library + would ship a broken feature its tests bless. - The type error is a more precise spec than a failing assertion, because it states the exact expected type before the logic exists. @@ -31,17 +30,16 @@ written before the runtime assertion, and both before the implementation. - Testing the type only: it would not catch handler wiring, `exhaustive()` throwing, or the `otherwise` fallback (see `src/index.test.ts`). -The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are -listed in +The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are in [CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands). `c8` uses V8 coverage, so the `--strip-types` source is instrumented without a -build step. The runner relies on the `.ts` import-extension convention (see +build step, and the runner relies on the `.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)). ## Known issues -- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, - so `src/index.test.ts` carries a file-level `oxlint-disable -typescript/no-floating-promises` with an explanatory comment. This is a known - false positive, not a rule we want off project-wide (see +- The type-aware linter misidentifies `expectTypeOf()` as a floating promise, so + `src/index.test.ts` carries a file-level `oxlint-disable +typescript/no-floating-promises` with an explanatory comment. It is a known + false positive, not a rule worth disabling project-wide (see [tooling.md § oxlint-disable directives live next to the code](./tooling.md#oxlint-disable-directives-live-next-to-the-code)). diff --git a/development/tooling.md b/development/tooling.md index 4ebf711..9d2b4c3 100644 --- a/development/tooling.md +++ b/development/tooling.md @@ -1,33 +1,32 @@ # Tooling -The choice and configuration of every tool below is the result of a deliberate -trade-off, not a default. This file is the record of those trade-offs. The -commands a contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md); the -tool versions are in [package.json](../package.json). +Every tool below was chosen and configured deliberately. The commands a +contributor runs are in [CONTRIBUTING.md](../CONTRIBUTING.md) and the versions +in [package.json](../package.json). ## Tool inventory - **TypeScript 7** — type checker and build (`tsc`). - **node --test** + `--strip-types` — test runner. -- **c8** — code coverage for `test:ci`. -- **oxlint** — Rust-based linter, with type-aware rules powered by - **oxlint-tsgolint** (typescript-go). -- **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, - JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting - replaces `sort-package-json`. +- **c8** — coverage for `test:ci`. +- **oxlint** — Rust linter, type-aware via **oxlint-tsgolint** (typescript-go). +- **oxfmt** — Rust formatter (Prettier-compatible) for JS/TS, JSON/JSONC, YAML, + Markdown, MDX, and more; its `package.json` key sorting replaces + `sort-package-json`. - **cspell** — spell checking. -- **knip** — finds unused dependencies, exports, and files. -- **check-outdated** — reports dependencies behind the registry; it exits - non-zero whenever _any_ dependency is outdated. -- **publint** — validates `package.json` for ESM publishing correctness. -- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against - multiple module-resolution scenarios. +- **knip** — unused dependencies, exports, and files. +- **check-outdated** — dependencies behind the registry; exits non-zero when any + is outdated. +- **publint** — validates `package.json` for ESM publishing. +- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` against module-resolution + scenarios. - **lefthook** — git hooks. -- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents - (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via +- **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents + (project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via `tsc --lsp --stdio`. -When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers). +When each runs is in +[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers). ## TypeScript and build @@ -36,44 +35,43 @@ When each tool runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md #### Decision (2026-09) `tsconfig.json` extends `@tsconfig/strictest` + `@tsconfig/node26`. -`tsconfig.build.json` extends it to add the emit-only options (`declaration`, -`sourceMap`, `inlineSources`, `outDir`, `target: es2024`, -`rewriteRelativeImportExtensions: true`) and to exclude test files. +`tsconfig.build.json` adds the emit-only options (`declaration`, `sourceMap`, +`inlineSources`, `outDir`, `target: es2024`, +`rewriteRelativeImportExtensions: true`) and excludes test files. #### Why -- It lets the editor and CI type-check from one config while the build emits - from the other, so a test file cannot leak into `dist/`. +- The editor and CI type-check from one config while the build emits from the + other, so a test file cannot leak into `dist/`. - `inlineSources` embeds the original TypeScript in `dist/*.js.map`, so - debuggers can map into `src/` without it being shipped. -- `declarationMap` is intentionally off: a `.d.ts.map` cannot embed source and - would dangle. + debuggers map into `src/` without it being shipped. +- `declarationMap` stays off: a `.d.ts.map` cannot embed source and would + dangle. ### The build starts from an empty `dist/` #### Decision (2026-09) -`npm run build` first runs a `prebuild` hook that empties `dist/`. +`npm run build` runs a `prebuild` hook that empties `dist/`. #### Why -- `tsc` does not prune orphaned emit output. Dropping `declarationMap`, for - example, left stale `*.d.ts.map` files behind, so the build must start from an - empty `dist/` to be reproducible. -- `prebuild` removes only `dist`; the manual `clean` still resets `dist` + - `coverage`, so a local coverage report survives a build. +- `tsc` does not prune orphaned emit output — dropping `declarationMap` left + stale `*.d.ts.map` files — so reproducibility needs an empty `dist/`. +- `prebuild` removes only `dist`; the manual `clean` resets `dist` + `coverage`, + so a local coverage report survives a build. ### Source imports use `.ts` extensions #### Decision (2026-09) -Source imports use `.ts` extensions, never `.js`. +Source imports use `.ts`, never `.js`. #### Why -- `node --strip-types` only resolves the `.ts` form at test time. -- `rewriteRelativeImportExtensions: true` in `tsconfig.build.json` rewrites them - to `.js` in the emitted JavaScript. +- `node --strip-types` resolves the `.ts` form at test time. +- `rewriteRelativeImportExtensions` rewrites them to `.js` in the emitted + JavaScript. - The emitted `.d.ts` keep the `.ts` specifier, which TypeScript >= 5.0 resolves (see [README § Requirements](../README.md#requirements)), so no post-processing step is needed. @@ -89,43 +87,41 @@ Source imports use `.ts` extensions, never `.js`. #### Decision (2026-09) -Type-aware oxlint is enabled declaratively via `options.typeAware: true` in -`.oxlintrc.json` (powered by `oxlint-tsgolint`). +Type-aware oxlint is enabled via `options.typeAware: true` in `.oxlintrc.json` +(powered by `oxlint-tsgolint`). #### Why - The script commands stay clean — no CLI flag. -- Type-aware mode is a property of the config, not the invocation, so it cannot - be forgotten on one call site. +- A config property cannot be forgotten on one call site. #### Rejected -- Passing a CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the - mode in two places and invites them to drift. +- A CLI flag in the `check:oxlint` / `fix:oxlint` scripts: it puts the mode in + two places and invites them to drift. ### `oxlint-disable` directives live next to the code #### Decision (2026-09) -Source-level `oxlint-disable` directives are used for known type-aware false -positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), rather -than rules being disabled in `.oxlintrc.json`. +Known type-aware false positives are silenced with source-level `oxlint-disable` +directives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`), not with +rules disabled in `.oxlintrc.json`. #### Why -- The disable lives next to the code it silences, so the trade-off is visible to - anyone reading the source. +- The disable sits next to the code it silences, visible to anyone reading the + source. #### Rejected -- Silencing a rule project-wide in `.oxlintrc.json`: it hides the suppression - from the reader of the affected code. +- A project-wide disable in `.oxlintrc.json`: it hides the suppression from the + reader of the affected code. #### Known issue -- A source-level disable is a _human_ last-resort convention. AI coding agents - must not add one; they fix the type at its root instead (see - [AGENTS.md § Never do](../AGENTS.md#never-do)). +- A source-level disable is a _human_ last resort. AI agents must not add one; + they fix the type at its root (see [AGENTS.md § Never do](../AGENTS.md#never-do)). ### `check:tsc` runs first @@ -135,24 +131,23 @@ than rules being disabled in `.oxlintrc.json`. #### Why -- A type error short-circuits the rest, which is faster feedback than letting - oxlint/oxfmt run and then failing on `tsc` at the end. +- A type error short-circuits the rest, which is faster than running + oxlint/oxfmt and failing on `tsc` at the end. ### `.editorconfig` is a fallback, not a gate #### Decision (2026-09) -`.editorconfig` exists for editor compatibility. Where both apply, +`.editorconfig` exists for editor compatibility; where both apply, `.oxfmtrc.json` is authoritative. #### Why -- `.editorconfig` is a sane fallback for the files oxfmt does not format (shell - scripts, dotfiles, `LICENSE`, the commit-message template, and git's - `COMMIT_EDITMSG` buffer). -- oxfmt is the formatter; the overlapping `.editorconfig` keys only keep - non-oxfmt editors close to the formatted result, so they cannot disagree with - the checker. +- `.editorconfig` covers the files oxfmt does not format: shell scripts, + dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG` + buffer. +- oxfmt is the formatter; the overlapping keys only keep non-oxfmt editors close + to the formatted result, so they cannot disagree with the checker. ## Static analysis and packaging @@ -160,14 +155,13 @@ than rules being disabled in `.oxlintrc.json`. #### Decision (2026-09) -`knip --include dependencies,exports,files` intentionally omits the `types` -category. +`knip --include dependencies,exports,files` omits the `types` category. #### Why -- The `types` category produces systematic false positives for libraries whose - exported types are part of the public API. -- The targeted scope keeps the signal high without config-file boilerplate. +- `types` produces systematic false positives for libraries whose exported types + are part of the public API. +- The narrower scope keeps the signal high without config-file boilerplate. ### `attw` targets ESM-only @@ -177,9 +171,8 @@ category. #### Why -- It is semantically correct: this package is intentionally ESM-only (no - CommonJS shim), so CJS resolution scenarios are out of scope by design, not a - bug. +- The package is intentionally ESM-only (no CommonJS shim), so CJS resolution + scenarios are out of scope by design, not a bug. ### `tslib` and `type-fest` are deliberately not used @@ -189,10 +182,10 @@ Neither `tslib` nor `type-fest` is a dependency. #### Why -- `tslib` is a runtime helper for old ES3/ES5 targets; the project targets +- `tslib` is a runtime helper for old ES3/ES5 targets; this project targets ES2024. - `type-fest` was never imported. -- `knip` flagged both, which is the same signal that keeps the list honest. +- `knip` flagged both, the same signal that keeps the list honest. ## Git hooks and script wiring @@ -200,15 +193,14 @@ Neither `tslib` nor `type-fest` is a dependency. #### Decision (2026-09) -The pre-commit hook sets the `LEFTHOOK_FILES` env var to the staged-files list, -and the affected scripts use `${LEFTHOOK_FILES:-}` to default to the -whole project when invoked manually. +The pre-commit hook sets `LEFTHOOK_FILES` to the staged-files list, and the +affected scripts use `${LEFTHOOK_FILES:-}` to default to the whole +project. #### Why -- It keeps `package.json#scripts` as the single source of truth for the - underlying commands — `lefthook.yml` only describes _what to run on which - files_. +- It keeps `package.json#scripts` the single source of truth; `lefthook.yml` + only says what to run on which files. - The same script works by hand (whole project) and staged (scoped), so there is no second command to maintain. @@ -216,13 +208,13 @@ whole project when invoked manually. ### VSCode integration -- Recommended extensions: see +- Recommended extensions are in [.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript native-preview, EditorConfig, todo-tasks). -- TypeScript 7 is used via the `typescriptteam.native-preview` extension. +- TypeScript 7 runs via the `typescriptteam.native-preview` extension. - The oxc extension provides oxlint squiggles and oxfmt format-on-save; - `.vscode/settings.json` pins it per language so a user's local `[language]` - formatter settings cannot override the project's choice. + `.vscode/settings.json` pins it per language so a local `[language]` formatter + setting cannot override the project's choice. ### `@spences10/pi-lsp` is pinned and read-only @@ -232,16 +224,15 @@ whole project when invoked manually. #### Why -- The package inspects `node_modules/typescript`, sees major >= 7 with no +- It inspects `node_modules/typescript`, sees major >= 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the - repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` - dependency is required. + repo's own `tsc --lsp --stdio` — no `typescript-language-server` dependency is + needed. - Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. -- The tool is _intermediate_ agent feedback (hover, references, definition, - symbols, diagnostics). It has no rename / code-action / apply-edit surface, - and is never a correctness gate — `npm run check` / `verify` remain that. -- `.pi/settings.json` is the shared, committed declaration; `.pi/npm/` is a - gitignored install cache that pi recreates automatically on a trusted startup - (it runs `npm install` for any missing project package), so the cache is - deliberately not tracked. +- It is _intermediate_ agent feedback (hover, references, definition, symbols, + diagnostics), with no rename / code-action / apply-edit surface, and is never a + gate — `npm run check` / `verify` are. +- `.pi/settings.json` is the committed declaration; `.pi/npm/` is a gitignored + install cache that pi recreates on a trusted startup (running `npm install` + for any missing project package), so it is deliberately not tracked. diff --git a/development/workflow.md b/development/workflow.md index 4e23629..bb7e518 100644 --- a/development/workflow.md +++ b/development/workflow.md @@ -1,164 +1,143 @@ # Workflow -How work moves through the repository: the branching model, the `npm run` -script taxonomy, the feedback tiers, and commit messages. The contributor-facing -steps are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they -are shaped the way they are. +How work moves through the repository. The rules are in +[CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they are shaped the +way they are. ## Branching model -The contributor-facing steps are in -[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Two -bits of context behind the model: collaborative review through the Gitea UI is -not in place, so Gitea is the lab; and the project will be promoted to GitHub -once it is tested and ready for production. What follows is why the front doors -exist and what was rejected. +The model is GitHub Flow (single-developer); the steps are in +[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model). Context +behind it: Gitea has no collaborative review UI in use, so it is the lab, and +the project moves to GitHub once it is tested and ready. #### Decision (2026-09) -Work is opened and closed by `create:branch` / `create:finish` rather than by -prose plus hand-written `git` commands. +Work is opened and closed by `create:branch` / `create:finish`, not by prose +plus hand-written `git`. #### Why -- The branching model's preconditions were prose. Prose rots silently — a rule - nobody checks is a suggestion. A script asserts, then acts, and the branch or - merge only happens if the assertions passed. -- The type-driven loop only produces trustworthy results if the baseline was - green before the first edit. Cheap checks run first and `npm run test` last, so - the expensive gate is not paid on a tree that was never eligible. -- `create:finish` owns the merge-side preconditions and the post-merge - `npm run verify`, so a merge cannot land unverified. The push stays with - `create:release` so the merge remains local and reviewable until then. -- Every check failure is non-mutating, except the baseline test which runs on - `main` after switching there — a red `main` restores the branch you started on - rather than stranding you on it. A merge conflict aborts and returns to the - feature branch, so a failed finish never leaves a half-merged `main`. +- The preconditions were prose, and prose rots: a rule nobody checks is a + suggestion. A script asserts, then acts, so the branch or merge only exists if + the assertions passed. +- Type-driven work is only trustworthy if the baseline was green before the + first edit. Cheap checks run first and `npm run test` last, so the expensive + gate is not paid on an ineligible tree. +- The merge half owns the post-merge `npm run verify`, so a merge cannot land + unverified. The push stays with `create:release` so the merge is reviewed + locally first. +- Every failure is non-mutating except the baseline test, which runs on `main` + after switching there: a red `main` restores the branch you started on, and a + merge conflict aborts back to the feature branch rather than stranding a + half-merged `main`. #### Rejected - Hand-written `git switch -c` / `git merge`: same rules, no enforcement. -- Reusing `pubv`'s preflight for `create:branch`: it is release-shaped and - third-party, and would make starting a branch pay for a build and pack it has - no use for. -- Leaving the merge as reviewer judgment: that judgment still exists, but it now - happens when the maintainer reviews the handover _before_ invoking - `create:finish`, rather than being encoded in a command that anyone can run - from a dirty tree. -- `--no-ff` rather than a fast-forward: it keeps each unit of work visible in - `git log`. +- Reusing `pubv`'s preflight for `create:branch`: release-shaped, third-party, + and it would pay for a build and pack a new branch has no use for. +- Leaving the merge to reviewer judgment: that judgment moved earlier, to the + handover review before `create:finish`, rather than living in a command anyone + can run from a dirty tree. +- Fast-forward instead of `--no-ff`: `--no-ff` keeps each unit of work visible + in `git log`. #### Known issue -- `create:finish` deliberately does not push, so `main` is ahead of - `origin/main` between a merge and the next push or release. `create:branch` - requires `main` to match its upstream and will refuse until it is pushed. Push - `main` before starting the next branch. +- `create:finish` does not push, so `main` is ahead of `origin/main` between a + merge and the next push. `create:branch` requires `main` to match its upstream + and refuses until it is pushed; push `main` before starting the next branch. ## Script prefix convention The prefix taxonomy is the rule, and it lives in [CONTRIBUTING.md § Script prefix convention](../CONTRIBUTING.md#script-prefix-convention). -What follows is the design rationale and the `create:` decision. - -One part of the taxonomy is not a prefix rule: some top-level scripts are -**bare** (no prefix): the entry points that either run a single tool (`build`, -`clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, -`maintain`, `setup`), plus one convenience that composes across tiers: `verify` -— composing `check` + `test:unit` into one whole-project correctness gate (it -deliberately uses `test:unit` rather than `test` because `check` already runs -`check:tsc`, so the type checker runs exactly once). Bare commands are how you -invoke a tier; the `prefix:*` scripts are what those tiers are made of. +The design behind it: bare scripts are the tier entry points — a single tool +(`build`, `clean`) or an aggregator of a `prefix:*` family (`check`, `fix`, +`test`, `watch`, `maintain`, `setup`) — while `verify` composes `check` + +`test:unit` into the whole-project gate (it uses `test:unit`, not `test`, +because `check` already runs `check:tsc`). #### Decision (2026-09) -`create:` is the prefix for workflow front doors, and there is no bare `create` +`create:` is the prefix for workflow front doors, with no bare `create` aggregator. #### Why -- Both members really do create something: a branch, a release. -- The prefix was added to both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in - the same commit as its first members, because a prefix missing from those - lists is invisible — which was the `use:` mistake this repo previously - carried (since retired into `setup:`). +- Both members create something real: a branch, a release. +- It joined both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) alongside its + first members, so it could not go invisible the way the retired `use:` prefix + did. - `publish:*` already set the precedent for a prefix without an aggregator. #### Rejected -- `run:` / `perform:` — both mean only "do the thing named after them", so every - script in the repo would fit under them and the taxonomy collapses. -- `git:` — names the tool, not the lifecycle moment, and advertises passthrough +- `run:` / `perform:`: they mean only "do the named thing", so every script fits + and the taxonomy collapses. +- `git:`: names the tool, not the lifecycle moment, and implies passthrough aliases. -- `start:` — describes the branch half, not the release. -- `cut:` — idiomatic for both, but it needs VCS slang to decode, and a signpost - that has to be explained is not one. -- `flow:` — overloaded in a library about type-level matching. -- The existing families: `check:*` is read-only and aggregated by `check`, so CI - would run a command that mutates repo state; `fix:*`'s review surface is a file - diff, not a branch; `maintain:*` is advisory and explicitly never a gate. +- `start:`: describes the branch half, not the release. +- `cut:`: idiomatic but needs VCS slang to decode. +- `flow:`: overloaded in a type-level matching library. +- The existing families: `check:*` is read-only (CI would run a state-mutating + command), `fix:*` reviews as a diff not a branch, `maintain:*` is advisory and + never a gate. ## Feedback tiers -The tier table and the rules for invoking it are in +The table and invocation rules are in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this -section explains why the split is where it is. +section explains the split. #### Decision (2026-09) -Split fast, offline, staged-file checks into pre-commit; whole-project test runs -into pre-push and `verify`; and slow or network-bound scans into `maintain`. +Fast, offline, staged-file checks sit in pre-commit; whole-project test runs in +pre-push and `verify`; slow or network-bound scans under `maintain`. #### Why -- **`watch:*` is a manual tier, not a hook.** The developer starts it on demand - (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It - sits as the earliest tier in the ladder, catching failures the moment a file is - saved — before staging, before commit. -- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in - pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally - scope to staged files via the `LEFTHOOK_FILES` env var convention. They give - instant feedback on what you typed. -- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole - test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention - doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push - runs after all commits are made but before the push leaves the machine, - catching regressions that span multiple commits. +- `watch:*` runs until killed, in its own pane, so it is the earliest tier, + firing on save before staging or commit. +- `check:tsc` / `check:oxlint` / `check:oxfmt` / `check:cspell` are fast + (~0.2–0.5s each), offline, and scope to staged files via `LEFTHOOK_FILES`, so + pre-commit gives instant feedback on what you typed. +- `test` (and its `tsc`) runs the whole suite over the whole project, and the + staged-file convention does not apply to the test runner, so it belongs in + pre-push, after the commits exist but before the push leaves the machine. #### Rejected -- Running `maintain:*` in `check` or pre-commit: advisory, whole-project and - network-bound scans are not correctness gates and would make the fast tier - slow. -- Treating a green pre-commit as the definition of done: it only sees staged - files, which is why `npm run verify` exists as the one-shot whole-project gate. -- A separate `git push` hook for `verify`: it would duplicate the pre-push test - tier; `verify` is run by hand because the push is where the whole project is - already checked. +- `maintain:*` in `check` or pre-commit: advisory, whole-project and + network-bound scans are not correctness gates and would slow the fast tier. +- Treating a green pre-commit as the definition of done: it sees only staged + files, hence `npm run verify`. +- A separate `git push` hook for `verify`: the pre-push test tier already covers + it. ## Commit messages The convention is in [CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages). -Examples from history: `:sparkles: Add watch tier with watch:test child`, +Examples: `:sparkles: Add watch tier with watch:test child`, `:recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The -body explains _what and why_, not _how_; link issues with `Resolves #...`. +body explains what and why, not how; link issues with `Resolves #...`. #### Decision (2026-09) -Gitmoji subjects with imperative mood, wrapped 50/72, rather than Conventional -Commits. +Gitmoji subjects, imperative mood, wrapped 50/72, not Conventional Commits. #### Why -- The history is gitmoji, not Conventional, and it predates any commit-lint - tooling; switching would rewrite the convention for no gain. -- The body carries reasoning, which is what a reviewer needs; the subject is a - signpost, not a semantic key. +- The history is gitmoji and predates any commit-lint tooling; switching would + rewrite the convention for no gain. +- The body carries the reasoning a reviewer needs; the subject is a signpost, + not a semantic key. #### Rejected - Conventional Commits: the release flow uses hand-written Keep-a-Changelog - notes, not generated ones, so the prefix carries no automation value here (see + notes, not generated ones, so the prefix has no automation value here (see [publishing.md](./publishing.md)). From 5e7d40b013a7de189448508be689116a8f9977c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 21:46:41 +0000 Subject: [PATCH 11/12] :memo: Improve docs after split --- CONTRIBUTING.md | 4 +- README.md | 123 +++----------------------------------- development/ci.md | 4 +- development/library.md | 102 +------------------------------ development/publishing.md | 6 +- development/tooling.md | 6 +- 6 files changed, 17 insertions(+), 228 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4d86df..04cdfb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ the rules so agents and humans don't diverge. 1. Clone the repository. 2. Install Node.js >= 26 — see [.node-version](./.node-version); the exact pinned version is what CI and the runner image use. -3. `npm install`. +3. `npm ci`. 4. `npm run setup` — the one-time clone configuration (currently registers the commit-message template). @@ -20,7 +20,7 @@ the rules so agents and humans don't diverge. - **Build:** `npm run build` - **Test:** `npm run test`, `npm run test:ci` -- **Watch:** `npm run watch` +- **Watch:** `npm run watch` - re-runs tests on file save, humans only - **Checks:** `npm run check`, `npm run fix` - **Verify:** `npm run verify` — the definition of done - **Maintenance:** `npm run maintain` — advisory only diff --git a/README.md b/README.md index 681a3de..63dfa9b 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex). ## Synopsis -```sh -npm install tiny-pattern-ts -``` - ```ts import { match, P } from "tiny-pattern-ts"; @@ -20,14 +16,6 @@ const reply = (answer: "yes" | "no") => reply("yes"); // "agreed" ``` -### Requirements - -- **Node.js >= 26** (`engines` field; pinned via `.node-version`). -- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts` - use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; - both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. -- The package is **ESM-only** (no CommonJS shim). - ## Description `tiny-pattern-ts` gives TypeScript the shape of F#-style pattern matching: @@ -43,6 +31,14 @@ The type-level contract is the feature — see [development/library.md](./development/library.md) for the design decisions and the known limitations. +## Requirements + +- **Node.js >= 26** (`engines` field; pinned via `.node-version`). +- **TypeScript >= 5.0** to consume the published declarations. The emitted `.d.ts` + use `const` type parameters (TS 5.0) and keep their relative `.ts` specifiers; + both resolve on TS >= 5.0 in `node10` / `node16` / `nodenext` / `bundler`. +- The package is **ESM-only** (no CommonJS shim). + ## Examples ### Literal matching and `exhaustive()` @@ -163,108 +159,7 @@ const firstNumber = (items: readonly unknown[]): number | undefined => ## API -### `match(value)` - -```ts -const match: (value: T) => MatchBuilder; -``` - -Starts a matching chain for `value`. The builder is immutable: every `.with` -returns a new builder, so a partially built chain can be reused. - -#### `.with(pattern, handler)` - -```ts -with(pattern: Matcher, handler: (value: U) => V): MatchBuilder; -``` - -Adds a case. `handler` receives the value narrowed to `U`, and its return type -`V` is added to the builder's result union `R`. A pattern whose narrowed type is -not assignable to the matched value's type is a compile error. - -#### `.exhaustive()` - -```ts -exhaustive(): R; -``` - -Returns the result of the first matching case. Throws -`tiny-pattern-ts: match.exhaustive() called with no matching case` if none -matched. It does not statically prove that every union member is covered. - -#### `.otherwise(handler)` - -```ts -otherwise(handler: (value: T) => R): R; -``` - -Like a final catch-all case: runs `handler` if no earlier case matched. Unlike -`.exhaustive()`, it never throws. - -### `P.literal(value)` - -```ts -const P.literal: ( - value: L, -) => Matcher; -``` - -Matches a single literal with `===` and narrows to its literal type. - -### `P.type(type)` - -```ts -const P.type: ( - type: "string" | "number" | "boolean" | "bigint" | "symbol" | "undefined" | "object" | "function", -) => Matcher; -``` - -Matches a `typeof` result and narrows to the explicitly supplied `T`. `T` is not -inferred from the name, so the type parameter and the runtime name must agree. - -### `P.when(predicate)` - -```ts -const P.when: (predicate: (value: unknown) => value is T) => Matcher; -``` - -Wraps a type guard as a matcher. This is the constructor to prefer when you can -express the check as a guard. - -### `P.any(predicate)` - -```ts -const P.any: (predicate: (value: unknown) => boolean) => Matcher; -``` - -Wraps a boolean predicate and declares the narrowed type `T` yourself. Use it -only when a type guard is not expressible; prefer `P.when`. - -### `P.shape(shape, refine?)` - -```ts -const P.shape: ( - shape: S, - refine?: (value: S) => value is T, -) => Matcher; -``` - -Matches an object that has every key of `shape`. A shape value that is a -`Matcher` is applied; otherwise the value is compared with `===`. Pass `refine` -to narrow to `T`; without it, the matched type is `S`. - -### Types - -```ts -interface Matcher { - readonly matches: (value: unknown) => value is T; -} - -type Pattern = Matcher; -``` - -Every pattern constructor returns a `Matcher`. `Pattern` is an alias kept -for readability. +Yet to be implemented ## License diff --git a/development/ci.md b/development/ci.md index cc373df..374363e 100644 --- a/development/ci.md +++ b/development/ci.md @@ -36,8 +36,8 @@ overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes. #### Rejected - 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)). +- Caching Proxy (Squid or similar) — adds complexity to global setup +- Mounting the tool cache - No invalidation will fill the cache with stale versions ## Bumping Node diff --git a/development/library.md b/development/library.md index a0818c3..280e467 100644 --- a/development/library.md +++ b/development/library.md @@ -3,104 +3,4 @@ The type-level design of the public API and the limitations it carries. The user-facing reference is [README § API](../README.md#api). -## A type guard is the single primitive - -#### Decision (2026-09) - -Every pattern constructor returns a `Matcher` whose only member is -`matches: (value: unknown) => value is T`; `Pattern` is an alias. - -#### Why - -- A type guard is the one TypeScript construct that both narrows in an `if` and - composes into a chain, so the library needs no DSL and no transpiler. -- Because `matches` narrows, `match(value).with(pattern, handler)` types the - handler with no cast and no runtime tag. -- `Pattern` is an alias, so either name is the same type. - -## The builder is immutable - -#### Decision (2026-09) - -`.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 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 when no case matched; it does not statically prove that -every member of the input union has a case. - -#### Why - -- 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 wants - either an `.otherwise(...)` (which never throws) or to prove coverage - themselves. Tracking uncovered members is a possible future change. - -## `P.type` takes the type as a parameter - -#### Decision (2026-09) - -`P.type(name)` requires the caller to supply `T`; it is not inferred from the -`typeof` string. - -#### Why - -- A runtime string cannot carry a TypeScript type; the caller pairs the two, and - the compiler only checks that `T` is used consistently afterwards. - -#### Known issue - -- `T` and the runtime `name` can disagree (`P.type("string")` compiles), - because nothing ties them together. Prefer `P.when` with a real type guard. A - name-to-type conditional mapping is a possible future change. - -## `P.shape` narrows only through `refine` - -#### Decision (2026-09) - -`P.shape(shape, refine?)` returns `Matcher`, defaulting to `Matcher` -without a `refine`. - -#### Why - -- 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` - alone is easy to misuse. Prefer `P.when` when a guard is already available. - -## `P.any` exists for non-guard predicates - -#### Decision (2026-09) - -`P.any(predicate)` takes a boolean predicate and a declared `T`. - -#### Why - -- 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 - -- 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. +Currently the library is placeholder code. diff --git a/development/publishing.md b/development/publishing.md index 70d8d6d..3f019b0 100644 --- a/development/publishing.md +++ b/development/publishing.md @@ -82,16 +82,12 @@ 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 has no version line and does -not link `package.json`. +`package-lock.json` by `npm version`. #### Why - `release.sh` reads the version from the changelog, so the changelog is the 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, not remove the copy. #### Rejected diff --git a/development/tooling.md b/development/tooling.md index 9d2b4c3..e5d7d5b 100644 --- a/development/tooling.md +++ b/development/tooling.md @@ -174,18 +174,16 @@ rules disabled in `.oxlintrc.json`. - 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 +### `tslib` is deliberately not used #### Decision (2026-09) -Neither `tslib` nor `type-fest` is a dependency. +`tslib` is not a dependency. #### Why - `tslib` is a runtime helper for old ES3/ES5 targets; this project targets ES2024. -- `type-fest` was never imported. -- `knip` flagged both, the same signal that keeps the list honest. ## Git hooks and script wiring From 1465926783ea95eebb08956e6c1377c16aea35ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 15 Sep 2026 21:51:21 +0000 Subject: [PATCH 12/12] :memo: Check off the docs restructure and note it in the changelog --- CHANGELOG.md | 3 +++ backlog.tasks | 50 +++++++++++++++++++++++++------------------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d32fe7..4538762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- restructure the documentation: README.md for users, CONTRIBUTING.md for contributors, and development/ for the decisions, rejected alternatives and known issues +- document the decisions and known issues for CI, tooling, testing, publishing and the workflow + ## [0.1.5] - 2026-09-15 - improve CI configuration diff --git a/backlog.tasks b/backlog.tasks index 2bc147c..4c14002 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -24,32 +24,32 @@ Bugs: Enhancements: Documentation: -☐ Clean up CONTRIBUTING.md and README.md, create docs - ☐ Review existing documentation for accuracy and completeness - ☐ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors - ☐ Move the decisions, shortcomings and known issues out of README.md and CONTRIBUTING.md - ☐ Decided: category files under development/ (one per area), not ADRs. Each decision is a block with #### Decision (YYYY-MM) / #### Why / #### Rejected / #### Known issue; rationale in development/README.md - ☐ have a look at other well known repositories for inspiration on how to structure the docs - ☐ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now - ☐ make sure to preserve that information in the new docs - ☐ development/README.md - index and decision-block convention - ☐ development/workflow.md - branching, script prefixes, feedback tiers, commits - ☐ development/tooling.md - toolchain decisions and editor setup - ☐ development/testing.md - type-driven testing - ☐ development/ci.md - pipeline, runner image, coverage serving - ☐ development/publishing.md - release and npm publishing - ☐ development/library.md - public API design and its limitations - ☐ README.md - ☐ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license +✔ Clean up CONTRIBUTING.md and README.md, create docs @done + ✔ Review existing documentation for accuracy and completeness @done + ✔ README.md should be the main entry point for users, and CONTRIBUTING.md should be the main entry point for contributors @done + ✔ Move the decisions, shortcomings and known issues out of README.md and CONTRIBUTING.md @done + ✔ Decided: category files under development/ (one per area), not ADRs. Each decision is a block with #### Decision (YYYY-MM) / #### Why / #### Rejected / #### Known issue; rationale in development/README.md @done + ✔ have a look at other well known repositories for inspiration on how to structure the docs @done + ✔ often times a docs folder is used, but this usually contains further user of the library documentation, that is deployed to a website. Deployment is out of scope for now @done + ✔ make sure to preserve that information in the new docs @done + ✔ development/README.md - index and decision-block convention @done + ✔ development/workflow.md - branching, script prefixes, feedback tiers, commits @done + ✔ development/tooling.md - toolchain decisions and editor setup @done + ✔ development/testing.md - type-driven testing @done + ✔ development/ci.md - pipeline, runner image, coverage serving @done + ✔ development/publishing.md - release and npm publishing @done + ✔ development/library.md - public API design and its limitations @done + ✔ README.md @done + ✔ I really like the order perl documentation does it: name with a single line description, version, Synopsis, Description, examples, API reference, license @done (example: https://metacpan.org/pod/Scalar::Util) - ☐ should include a clear description of the library, its purpose, and how to use it - ☐ Add usage examples to README.md - ☐ version needs to be kept in sync with package.json in release.sh - ☐ Not every section in current README fits in the above order, so put them in another file - ☐ CONTRIBUTING.md - ☐ should include instructions for how to contribute to the project, including how to set up a development environment, run tests, and submit pull requests - ☐ should include guidelines for code style and formatting and a hint, that vscode extensions are suggested from .vscode/extensions.json - ☐ Not every section in current CONTRIBUTING.md fits in, so put them in another file + ✔ should include a clear description of the library, its purpose, and how to use it @done + ✔ Add usage examples to README.md @done + ✔ version needs to be kept in sync with package.json in release.sh @done + ✔ Not every section in current README fits in the above order, so put them in another file @done + ✔ CONTRIBUTING.md @done + ✔ should include instructions for how to contribute to the project, including how to set up a development environment, run tests, and submit pull requests @done + ✔ should include guidelines for code style and formatting and a hint, that vscode extensions are suggested from .vscode/extensions.json @done + ✔ Not every section in current CONTRIBUTING.md fits in, so put them in another file @done