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] :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)).