diff --git a/AGENTS.md b/AGENTS.md index cdaa80c..7372e24 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. 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 @@ -64,5 +65,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/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/CONTRIBUTING.md b/CONTRIBUTING.md index 6514cf9..04cdfb6 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 ci`. +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` - 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 +- **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,174 @@ 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 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. +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. 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. +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 + +`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 + +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 + +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)) +- **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 -**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). +- **Releases are NOT triggered by pushes.** Only the maintainer triggers a + release; see [Publishing](#publishing). -## 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). diff --git a/README.md b/README.md index 3bdec4f..63dfa9b 100644 --- a/README.md +++ b/README.md @@ -2,68 +2,172 @@ 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` +```ts +import { match, P } from "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). +const reply = (answer: "yes" | "no") => + match(answer) + .with(P.literal("yes"), (): "agreed" => "agreed") + .with(P.literal("no"), (): "declined" => "declined") + .exhaustive(); -### Tooling +reply("yes"); // "agreed" +``` -- **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`. +## Description -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). +`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. -### Tooling decisions +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. -The choice and configuration of each tool above is the result of deliberate trade-offs, not defaults. The non-obvious ones: +## Requirements -- **`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. +- **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). -### Requirements +## Examples -- 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`. +### Literal matching and `exhaustive()` -## VSCode integration +`.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`: -- 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. +```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 + +Yet to be implemented + +## 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). diff --git a/backlog.tasks b/backlog.tasks index dc8cee8..4c14002 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -24,12 +24,43 @@ Bugs: Enhancements: Documentation: -☐ Clean up CONTRIBUTING.md and README.md, create docs -☐ Add usage examples to README.md +✔ 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 @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 + + + ☐ 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 +☐ 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 @@ -54,7 +85,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..dd827ce --- /dev/null +++ b/development/README.md @@ -0,0 +1,78 @@ +# Development documentation + +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 — 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 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 + +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 | + +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 + +Record every non-obvious choice as a block in the relevant category file: + +```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 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: `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 new file mode 100644 index 0000000..374363e --- /dev/null +++ b/development/ci.md @@ -0,0 +1,136 @@ +# CI + +[.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`; 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`** — 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) + +`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 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 was the original problem. +- 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 + +Bumping Node is one coordinated change, committed as a unit: + +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`, which reads the version and pushes + `:`. +3. Repoint the three `container.image` tags in + [.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 + +For the `setup-node` probe to hit, two things must hold — 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 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 + +#### 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 + 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 + +- 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, 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 + +#### Decision (2026-09) + +Serve CI coverage from a shared directory on the runner, with no deploy step in +CI. + +#### Why + +- 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//`). + +#### Rejected + +- Gitea Pages and Codecov: neither was confirmed available or wanted. + +#### Known issue + +- 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 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 into sidecars rather than per request. + +#### Why + +- 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 new file mode 100644 index 0000000..280e467 --- /dev/null +++ b/development/library.md @@ -0,0 +1,6 @@ +# 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). + +Currently the library is placeholder code. diff --git a/development/publishing.md b/development/publishing.md new file mode 100644 index 0000000..3f019b0 --- /dev/null +++ b/development/publishing.md @@ -0,0 +1,131 @@ +# Publishing + +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); 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, 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 + +#### 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 and 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 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 + the notes are hand-written (see + [workflow.md § Commit messages](./workflow.md#commit-messages)). +- `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 + +#### 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`. + +#### 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. + +#### Rejected + +- 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 + 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 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 an empty body. + A leading `v` is tolerated so both `v1.2.3` and `1.2.3` match `## [1.2.3]`. + +## 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 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. + +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..d4ef8be --- /dev/null +++ b/development/testing.md @@ -0,0 +1,45 @@ +# Testing + +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. + +## Type-driven development + +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 and what was rejected. + +#### Decision (2026-09) + +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, 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. + +#### 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`). + +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, 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. 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 new file mode 100644 index 0000000..e5d7d5b --- /dev/null +++ b/development/tooling.md @@ -0,0 +1,236 @@ +# Tooling + +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** — 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** — 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 agents + (project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via + `tsc --lsp --stdio`. + +When each 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` adds the emit-only options (`declaration`, `sourceMap`, +`inlineSources`, `outDir`, `target: es2024`, +`rewriteRelativeImportExtensions: true`) and excludes test files. + +#### Why + +- 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 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` runs a `prebuild` hook that empties `dist/`. + +#### Why + +- `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`, never `.js`. + +#### Why + +- `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. + +#### 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 via `options.typeAware: true` in `.oxlintrc.json` +(powered by `oxlint-tsgolint`). + +#### Why + +- The script commands stay clean — no CLI flag. +- A config property cannot be forgotten on one call site. + +#### Rejected + +- 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) + +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 sits next to the code it silences, visible to anyone reading the + source. + +#### Rejected + +- 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. 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 + +#### Decision (2026-09) + +`check:tsc` runs first in the `npm run check` chain. + +#### Why + +- 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, +`.oxfmtrc.json` is authoritative. + +#### Why + +- `.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 + +### `knip` omits the `types` category + +#### Decision (2026-09) + +`knip --include dependencies,exports,files` omits the `types` category. + +#### Why + +- `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 + +#### Decision (2026-09) + +`attw --profile esm-only` is used. + +#### Why + +- The package is intentionally ESM-only (no CommonJS shim), so CJS resolution + scenarios are out of scope by design, not a bug. + +### `tslib` is deliberately not used + +#### Decision (2026-09) + +`tslib` is not a dependency. + +#### Why + +- `tslib` is a runtime helper for old ES3/ES5 targets; this project targets + ES2024. + +## Git hooks and script wiring + +### `LEFTHOOK_FILES` scopes commands to staged files + +#### Decision (2026-09) + +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` 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. + +## Editor and agent tooling + +### VSCode integration + +- Recommended extensions are in + [.vscode/extensions.json](../.vscode/extensions.json) (oxc, cspell, TypeScript + native-preview, EditorConfig, todo-tasks). +- 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 local `[language]` formatter + setting 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 + +- 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` — no `typescript-language-server` dependency is + needed. +- Earlier releases (`<= 0.0.10`) hard-wire to `typescript-language-server +--stdio` and are TS6-only. +- 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 new file mode 100644 index 0000000..bb7e518 --- /dev/null +++ b/development/workflow.md @@ -0,0 +1,143 @@ +# Workflow + +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 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`, not by prose +plus hand-written `git`. + +#### Why + +- 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`: 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` 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). +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, with no bare `create` +aggregator. + +#### Why + +- 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:`: 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 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 table and invocation rules are in +[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this +section explains the split. + +#### Decision (2026-09) + +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:*` 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 + +- `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: `: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, imperative mood, wrapped 50/72, not Conventional Commits. + +#### Why + +- 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 has no automation value here (see + [publishing.md](./publishing.md)). 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"