# Contributing 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. ## Setup 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). ## Development commands - **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": | Tier | When | What it runs | Time | | -------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | | `npm run watch` | manual | `watch:test` — re-runs tests on file save | ~0.1s | | Pre-commit (auto) | on stage | tsc + oxlint + oxfmt + cspell (staged files only) | ~1.3s | | Pre-push (auto) | on push | `npm test` (full tsc + unit tests) | ~3.5s | | `npm run check` | manual | Correctness gates: tsc + oxlint + oxfmt + cspell (whole project) | ~3s | | `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s | | `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~3s | | `npm run maintain` | manual / CI (advisory) | `maintain:knip` + `maintain:outdated` (whole-project + network scans) | ~10s | | CI build (auto) | on push to `main` / tag | `build` job (build + correctness + coverage + packaging) — see [.gitea/workflows/ci.yml](./.gitea/workflows/ci.yml) | ~30s+ | | 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 | 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, so development is **type-driven**: the compile-time expectation is written before the runtime assertion, and both before the implementation. The loop is **type → red → green → refactor**: 1. **Type** — write the compile-time expectation first (`expectTypeOf(...).toEqualTypeOf<…>()`) and let `npm run check:tsc` fail on the _type_. The type error is the spec you want to hit before the runtime logic exists. 2. **Red** — add the matching runtime assertion (`assert.*`) so `npm run test:unit` now fails on behavior. 3. **Green** — implement in `src/*.ts` until both the type check and the test pass. 4. **Refactor** — with the type system and the tests as the safety net, then `npm run verify` as the definition-of-done gate. Every test pairs an `expectTypeOf(...)` with an `assert.*`; keep them together — including the expectations inside handler bodies, which pair with an assertion on the value dispatch passed, not only the type it inferred (see [development/testing.md § Handler arguments](./development/testing.md#handler-arguments)). The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the helper, `src/primitive-union.test.ts` for the matcher's popup) are the exception — the language server, not the type system, is the oracle (see [development/testing.md § Autocomplete](./development/testing.md#autocomplete)). Each test body follows **AAA (Arrange–Act–Assert)** with labeled blocks separated by a blank line: `// Arrange` sets up the inputs (e.g. the matcher factory), `// Act` exercises the subject once from them (not a second throwaway call), `// Assert` holds every check — type expectations first, runtime assertions last; an empty block drops its label (see [development/testing.md § AAA ordering](./development/testing.md#aaa-ordering)). 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` runs the suite under c8 and fails below 100% coverage on `src/` (CI-only; `verify` stays coverage-free). - `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 one, nor edit `.oxlintrc.json` to silence a finding (e.g. `typescript/no-floating-promises`) — 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 branch ends with a changelog note:** before `npm run create:finish`, summarize the work under `[Unreleased]` in [CHANGELOG.md](./CHANGELOG.md). (why: [development/workflow.md](./development/workflow.md#changelog-notes)) - **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)) - **Prose in `development/` is extremely concise.** When adding or changing a decision, write fragments if needed — sacrifice grammar for concision. (why: [development/README.md § Decision blocks](./development/README.md#decision-blocks)) ## Branching model **GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit. There is no pull request workflow on Gitea yet. - **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, no merge/rebase/cherry-pick is in progress, `main` is not behind its upstream (a local merge not yet pushed is fine — the push belongs to `create:release`), 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). Full rationale, including the front-door decisions: [development/workflow.md § Branching model](./development/workflow.md#branching-model). ## Submitting changes There is no pull request workflow on Gitea yet, so a contribution is submitted as a branch that is merged locally: 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. Add a changelog note under `[Unreleased]` (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce)). 5. `npm run create:finish` to merge the branch into `main` and verify the result. 6. Present a handover for review. Once there are no further objections, the maintainer pushes. When the project is promoted to GitHub, this step becomes a normal pull request against `main`. ## Publishing Publishing is maintainer-only and CI-only. See [development/publishing.md](./development/publishing.md).