diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6514cf9..7f92ae3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,49 +1,36 @@ # Contributing -This document is for maintainers and contributors working on the project itself. End-user documentation is in [README.md](./README.md). The machine entry point for AI coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for the rules below so agents and humans don't diverge. +This document is for maintainers and contributors working on the project +itself. End-user documentation is in [README.md](./README.md). The reasons +behind the rules here — the decisions, rejected alternatives, and known issues — +live in [development/](./development/README.md). The machine entry point for AI +coding agents is [AGENTS.md](./AGENTS.md); keep this file as the prose home for +the rules so agents and humans don't diverge. -## Rules the tools don't enforce +## Setup -CI and review will bounce these even though `npm run check` and the linters don't catch them. They're the high-frequency things a contributor (or an agent) reaches for by default: +1. Clone the repository. +2. Install Node.js >= 26 — see [.node-version](./.node-version); the exact pinned + version is what CI and the runner image use. +3. `npm install`. +4. `npm run setup` — the one-time clone configuration (currently registers the + commit-message template). -- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` only resolves the `.ts` form at test time; "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions) -- **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention)) -- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions) -- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention)) -- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model)) -- **Work is merged back with `npm run create:finish`, never a hand-written `git merge`.** The command carries the merge-side preconditions (clean tree, current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` after the merge, so a merge cannot land unverified. (see [Branching model](#branching-model)) -- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) +## Development commands -## Editor configuration - -`.editorconfig` is for editor compatibility, not a gate — it is a sane fallback for the files oxfmt does not format (shell scripts, dotfiles, `LICENSE`, the commit-message template, and git's `COMMIT_EDITMSG` buffer). Where both apply, `.oxfmtrc.json` is authoritative: oxfmt is the formatter, and the overlapping `.editorconfig` keys only keep non-oxfmt editors close to the formatted result. - -## Commit messages - -Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run setup:git-commit-message` once after cloning to register it as git's `commit.template` (or `npm run setup` to run every one-time clone step). - -Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recycle: Move type-aware config to .oxlintrc.json; use source-level disable directives`, `:memo: Restore unique maintainer content as CONTRIBUTING.md`. The body explains _what and why_, not _how_; link issues with `Resolves #...`. - -## Script prefix convention - -Script names in `package.json` use a prefix that signals _when_ the script is intended to run. A `:` script is implicitly aggregated by a `` script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle: - -- `create:*` — front doors of the repo's own workflow; these mutate git state rather than the source. `create:branch` opens a unit of work (asserts a clean tree, a current `main` and a green baseline before it branches), `create:finish` closes the branch half (merges the current unit of work into `main` and verifies the result), `create:release` closes the release half (maintainer-only). No bare `create` aggregator on purpose — see `publish:*` for the precedent. -- `check:*` — read-only verification; never modifies files. Aggregated by `npm run check`. -- `fix:*` — mutating counterpart of a `check:*` script. Aggregated by `npm run fix`; the diff is the review surface. -- `test:*` — test scripts. `test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage. -- `watch:*` — long-running watchers for the manual inner dev loop. Aggregated by `watch`; currently a single child (`watch:test`), and a future `watch:oxlint` / `watch:tsc` would run concurrently under that umbrella. -- `maintain:*` — advisory repo-maintenance scans: read-only, but whole-project and/or network-bound, so never a correctness gate. Aggregated by `npm run maintain`. -- `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than the source, so it needs a fresh build. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow)) -- `setup:*` — one-time configuration of a fresh clone; mutates the local environment (git config, editor settings) rather than the repo source, so it is never part of a hook or CI step. Aggregated by `npm run setup` (the umbrella), run once after cloning. - -A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline. When it genuinely does belong, a new prefix is allowed — but it enters both lists in this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old `use:` prefix was exactly that; it is now retired into `setup:`). - -Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`, `setup`), plus one convenience that composes across tiers: `verify` — composing `check` + `test:unit` into one whole-project correctness gate (it deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once). Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of. +- **Build:** `npm run build` +- **Test:** `npm run test`, `npm run test:ci` +- **Watch:** `npm run watch` +- **Checks:** `npm run check`, `npm run fix` +- **Verify:** `npm run verify` — the definition of done +- **Maintenance:** `npm run maintain` — advisory only +- **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` ## Feedback tiers -The tools are organized into a feedback ladder. Each tier catches different things at different costs; the rule of thumb is "earlier tiers fire more often, faster tiers catch less, slower tiers are more thorough": +The tools are organized into a feedback ladder. Each tier catches different +things at different costs; the rule of thumb is "earlier tiers fire more often, +faster tiers catch less, slower tiers are more thorough": | Tier | When | What it runs | Time | | -------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | @@ -58,62 +45,128 @@ The tools are organized into a feedback ladder. Each tier catches different thin | CI maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s | | CI publish (auto) | on tag | packaging checks + `publish:publint` / `publish:attw`, then the Gitea release page and `npm publish` (skipped, and the job failed, without `NPM_TOKEN`) | ~15s | -### Why these splits? - -- **`watch:*` is a manual tier, not a hook.** The developer starts it on demand (it has to be killed with Ctrl-C) and it runs in a dedicated terminal pane. It sits as the earliest tier in the feedback ladder, catching failures the moment a file is saved — before staging, before commit. -- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally scope to staged files via the `LEFTHOOK_FILES` env var convention. They give instant feedback on what you typed. -- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention doesn't apply to the test runner, so pre-commit isn't the right home. Pre-push runs after all commits are made but before the push leaves the machine, catching regressions that span multiple commits. - -### Before pushing - -Run `npm run verify` — the one-shot correctness gate in the table above. Run `npm run maintain` only on a maintenance / update-deps branch. +Before pushing, run `npm run verify` — the one-shot correctness gate. Run +`npm run maintain` only on a maintenance / update-deps branch. Why the splits +are where they are: [development/workflow.md § Feedback tiers](./development/workflow.md#feedback-tiers). ## Testing discipline (type-driven) -For this library the types _are_ the feature — narrowing, `exhaustive()` returns, the `Matcher` contract — so a runtime-only test loop would verify the wrong thing. New behavior follows **type-driven development** (in Edwin Brady's sense): _treat the type as the plan for a program, and use the compiler and type checker as your assistant, guiding you to a complete program that satisfies the type_ ([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the `expectTypeOf` assertion, written first. The loop is **type → red → green → refactor**: +For this library the types _are_ the feature, so the loop is **type → red → +green → refactor**: write the `expectTypeOf(...)` assertion first, then the +runtime `assert.*`, then the implementation. Every test pairs the two; keep +them together. Type-first is enforced structurally: `npm test` runs +`check:tsc` before the test runner, so a wrong type can never be papered over by +a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach +green honestly — fix the types, never suppress the checks you can't make pass. +Full rationale: [development/testing.md](./development/testing.md). -1. **Type** — write the compile-time expectation first (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on the _type_. The type error is the spec you want to hit before the runtime logic exists. -2. **Red** — add the matching runtime assertion (`assert.*`) so `npm run test:unit` now fails on behavior. -3. **Green** — implement in `src/*.ts` until both the type check and the test pass. -4. **Refactor** — with the type system and the tests as the safety net, then `npm run verify` as the definition-of-done gate. +## Code style and formatting -This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass. +`oxfmt` is the formatter and `oxlint` is the linter (with type-aware rules). +`npm run fix` resolves the fixable issues; `npm run check` verifies without +writing. Suppressions must be fixed at the root — do not add `oxlint-disable` +directives or `as` casts to force a green run (see +[AGENTS.md § Never do](./AGENTS.md#never-do)). + +Suggested VSCode extensions are in +[.vscode/extensions.json](./.vscode/extensions.json); the project's formatter +and linter are wired up there. Toolchain decisions: +[development/tooling.md](./development/tooling.md). + +## Commit messages + +Gitmoji subject, imperative mood, 50/72 wrapping. The template is +[commit-message-template](./commit-message-template); `npm run setup` +(or `npm run setup:git-commit-message`) registers it as git's +`commit.template`. Examples and rationale: +[development/workflow.md § Commit messages](./development/workflow.md#commit-messages). + +## Script prefix convention + +A new `npm run` script must reuse an existing prefix: `create:` / `check:` / +`fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`. If none fits, +that's a signal the script doesn't belong in the pipeline — not a reason to +invent a new prefix. If it genuinely does belong, add the prefix to the list +here in the same commit as its first member; an undocumented prefix becomes +invisible and quietly accrues members. Full convention and why `create:` exists: +[development/workflow.md § Script prefix convention](./development/workflow.md#script-prefix-convention). + +## Rules the tools don't enforce + +CI and review will bounce these even though `npm run check` and the linters +don't catch them. They're the high-frequency things a contributor (or an agent) +reaches for by default: + +- **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` + only resolves the `.ts` form at test time; "pre-fixing" an import to `.js` + breaks the inner loop. (why: + [development/tooling.md](./development/tooling.md#source-imports-use-ts-extensions)) +- **A new `npm run` script must reuse an existing prefix.** See + [Script prefix convention](#script-prefix-convention). +- **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The + trade-off must sit next to the code it silences. This is a _human_ last-resort + convention; agents must not add these — see + [AGENTS.md § Never do](./AGENTS.md#never-do). (why: + [development/tooling.md](./development/tooling.md#oxlint-disable-directives-live-next-to-the-code)) +- **Don't put slow / network / whole-project scans in `check` or pre-commit.** + Advisory scans are not correctness gates; they belong under `maintain:`. (why: + [development/workflow.md](./development/workflow.md#feedback-tiers)) +- **New work starts with `npm run create:branch`, never a hand-written + `git switch -c` / `git checkout -b`.** The command carries the branch + precondition; branching around it skips the clean-tree, current-`main` and + green-baseline checks, and the skip is invisible until a failure can no longer + be attributed. (why: + [development/workflow.md](./development/workflow.md#branching-model)) +- **Work is merged back with `npm run create:finish`, never a hand-written + `git merge`.** The command carries the merge-side preconditions (clean tree, + current `main`, a `feature/`/`fix/`/`chore/` branch) and runs `npm run verify` + after the merge, so a merge cannot land unverified. (why: + [development/workflow.md](./development/workflow.md#branching-model)) +- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` + don't go in `check`.** (why: + [development/publishing.md](./development/publishing.md#ci-only-publishing)) ## Branching model -**GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — Gitea is the lab; when something is tested and ready for production it will be promoted to GitHub. +**GitHub Flow (single-developer).** Every change — feature, fix, refactor — +branches off `main` and is merged back via a local commit. There is no pull +request workflow on Gitea yet. - **Base branch:** `main` - **Branch naming:** `feature/` / `fix/` / `chore/` -- **Starting work:** `npm run create:branch -- /`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it. -- **Merging:** `npm run create:finish` (on the branch). It asserts the same clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale `main` (a true divergence is refused), merges the branch `--no-ff`, runs `npm run verify`, and deletes the branch only after the merge is green. The push is deliberately left to `create:release`, so the merge stays local and reviewable — read the diff yourself before finishing. -- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. The one exception: a push headed by a release commit (`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because `create:release` pushes the tag for that exact commit right after and the tag run is the authoritative one (see `release-gate` in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml)). -- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). +- **Starting work:** `npm run create:branch -- /`. It refuses, + without changing anything, unless the working tree is clean, no + merge/rebase/cherry-pick is in progress, `main` matches its upstream, and + `npm run test` is green on `main`. The prefix is _your_ call, inferred from the + task; the script validates it rather than guessing it. +- **Merging:** `npm run create:finish` (on the branch). It re-asserts the same + preconditions, merges `--no-ff`, runs `npm run verify`, and deletes the branch + only after the merge is green. The push is left to `create:release`, so the + merge stays local and reviewable. +- CI runs on every push to `main` — see [Feedback tiers](#feedback-tiers) and + [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml). -## CI runner image +Full rationale, including the front-door decisions and a known issue about +`main` being ahead of its upstream between a merge and the next push: +[development/workflow.md § Branching model](./development/workflow.md#branching-model). -The `build` / `maintain` / `publish` jobs run in `gitea.e1nsnull.de/tmu/act-ci:` ([docker/Dockerfile](./docker/Dockerfile)) — the runner's default act image with the Node distribution overlaid at the exact `/opt/hostedtoolcache` layout `actions/setup-node` probes before downloading, so no job pays the ~50 MB fetch. The image tag MUST equal the exact version pinned in `.node-version`, and the image is rebuilt only as part of a Node bump — there is no other trigger. `release-gate` uses no Node and stays on the default image. The script is deliberately NOT an `npm run` script: building requires a docker daemon and registry credentials, so it belongs to no feedback tier — per [Script prefix convention](#script-prefix-convention), no existing prefix fits and that is the signal. +## Submitting changes -Bumping Node is one coordinated change, committed as a unit: +There is no pull request workflow on Gitea yet, so a contribution is submitted +as a branch that is merged locally: -1. Edit `.node-version` to the new exact `x.y.z` — floats like `26` resolve to the latest patch at runtime and silently bust the baked entry; `scripts/runner-image.sh` refuses them. -2. `docker login gitea.e1nsnull.de` (user + package/access token), then `./scripts/runner-image.sh --push` — it reads the version from `.node-version` and builds/pushes `:`. -3. Repoint the three `container.image` tags in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) to the same version. +1. `npm run create:branch -- /`. +2. Commit your work (one or more commits, per the tests and style rules above). +3. `npm run verify` — the definition of done. +4. `npm run create:finish` to merge the branch into `main` and verify the + result. +5. Present a handover for review. Once there are no further objections, the + maintainer pushes. -Skipping step 2 fails CI at image pull; skipping step 3 silently reverts to the per-job download. +When the project is promoted to GitHub, this step becomes a normal pull request +against `main`. -Two invariants the image must satisfy for the probe to hit, both easy to break: +## Publishing -- **The `x64.complete` marker.** `actions/tool-cache` accepts a cached tool only when `/.complete` exists next to the directory (`tc.find()` checks it); a plausible-looking `node//x64/` alone is ignored and the download happens anyway. See the comment in [docker/Dockerfile](./docker/Dockerfile). -- **Tag freshness (force-pull deliberately off).** The tag encodes only the Node version, and the image is rebuilt only when that version changes — so the normal flow always yields a new tag, and `act_runner` pulls it. `force_pull` stays disabled (the job log shows `forcePull=false`): it is acceptable to miss a runner-side image change, and forcing a pull would re-pull the image on every job for no benefit. Known issue: a Dockerfile-only change (like the marker above) re-pushed under an unchanged tag is invisible to the runner — it keeps the old image while the registry shows the new digest. If that ever matters, remove the stale tag on the runner host (`docker rmi gitea.e1nsnull.de/tmu/act-ci:`); do not reach for force-pull. - -## Publishing workflow - -Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`: - -1. All intended changes are merged to `main` and passing CI. -2. The maintainer runs `npm run create:release`. VS Code opens `CHANGELOG.md` to finalize the `[Unreleased]` notes; because pubv refuses a dirty tree, any edit is committed first (then folded into the release commit), and pubv's interactive prompt suggests a version from those notes — the maintainer confirms or edits it. -3. `scripts/release.sh` creates a single release commit (graduated changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea. -4. CI fires on both pushes: the `publish` job runs on the tag (`build` + publish-tier checks + release page + `npm publish`), while the branch run's `release-gate` job recognizes the release commit and skips `build`/`maintain` — the tag verifies the identical SHA, so no work is duplicated. The job graph lives in [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) — keep that file, not this list, as the source of truth. The publish-tier checks must pass before the artifact is published. The `publish` job also creates the Gitea release page from the matching Keep-a-Changelog section (`scripts/release-notes.sh`); it runs _before_ `npm publish` so a broken page fails CI without consuming a version, and `npm publish` stays the last step. - -The Gitea release page uses the run's automatic token (`github.token`), so it only needs `contents: write`. `npm publish` is gated on `NPM_TOKEN`, lifted into job-level `env` because `secrets` is not an allowed context in a step `if`: an unset secret skips the publish instead of attempting an unauthenticated one. A tag is all-or-nothing, though — a final `always()` step fails the job unless both the release page and `npm publish` reported `success`, so a skipped or failed npm half turns the job red rather than silently green. Set `NPM_TOKEN` (npm publish rights) under Settings → Actions → Secrets. +Publishing is maintainer-only and CI-only. See +[development/publishing.md](./development/publishing.md).