The pipeline bullet's "(push to `main` / tag)" read like a local push hook. State plainly that `compat` has no local tier — it is not in `check`, `verify`, `test:ci` or any hook — and is run manually with `build && test:compat`.
16 KiB
Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in README.md. The reasons behind the rules here — the decisions, rejected alternatives, and known issues — live in development/. The machine entry point for AI coding agents is AGENTS.md; keep this file as the prose home for the rules so agents and humans don't diverge.
Setup
- Clone the repository.
- Install Node.js >= 26 — see .node-version; the exact pinned version is what CI and the runner image use.
npm ci.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 - Compat (CI-only, manual locally):
npm run test:compat— type-check the suite + a consumer fixture against the minimum supported TypeScript; needs a builtdist/and network access (npx). Never part of a hook orcheck/verify - Watch:
npm run watch- re-runs tests on file save, humans only - Checks:
npm run check,npm run fix - Doc tests:
npm run create:doc-tests— compile thets-tagged fences in the prose docs into executed, gitignored tests - 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 |
~30s+ |
| CI compat (auto) | in CI only | compat job — test:compat over the built dist/; never a local tier (run by hand) — see compat/ |
~15s |
| 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.
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:
- Type — write the compile-time expectation first
(
expectTypeOf(...).toEqualTypeOf<…>()) and letnpm run check:tscfail on the type. The type error is the spec you want to hit before the runtime logic exists. - Red — add the matching runtime assertion (
assert.*) sonpm run test:unitnow fails on behavior. - Green — implement in
src/*.tsuntil both the type check and the test pass. - Refactor — with the type system and the tests as the safety net, then
npm run verifyas 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).
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).
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).
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, reach green honestly — fix the
types, never suppress the checks you can't make pass. Full rationale:
development/testing.md.
Documentation examples
Every ts-tagged fence in README.md / CONTRIBUTING.md is compiled into an
executed test, so a documented example cannot drift from the API. Describe each
fence with the paragraph directly above it (that text becomes the test title),
and keep its library import self-contained;
npm run create:doc-tests regenerates, formats and type-checks the tests under
src/doc-test/__generated__/. CI runs it in an explicit step before test:ci,
so run it yourself before npm run verify when you touched a fence. Why:
development/docs.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).
Suggested VSCode extensions are in .vscode/extensions.json; the project's formatter and linter are wired up there. Toolchain decisions: development/tooling.md.
Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is
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.
Script prefix convention
Script names in package.json use a prefix that signals when the script is
intended to run. A <prefix>:<name> script is implicitly aggregated by a
<prefix> 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 produce or mutate workflow artifacts (git state, generated doc-tests) rather than the hand-written source.create:branchopens a unit of work,create:finishcloses the branch half,create:releasecloses the release half (maintainer-only), andcreate:doc-testsregenerates the compiled prose examples. No barecreateaggregator on purpose.check:*— read-only verification; never modifies files. Aggregated bynpm run check.fix:*— mutating counterpart of acheck:*script. Aggregated bynpm run fix; the diff is the review surface.test:*— test scripts.testis the canonical entry point (check:tsc+ unit tests);test:unitskips the typecheck for fast local iteration;test:coverageruns c8 over the hand-written tests only;test:docruns the generated doc examples without coverage;test:cichains the two and fails below 100% coverage onsrc/;test:compattype-checks the suite + a consumer fixture against the minimum supported TypeScript vianpx(both CI-only;verifystays coverage- and network-free).watch:*— long-running watchers for the manual inner dev loop. Aggregated bywatch.maintain:*— advisory repo-maintenance scans: read-only, but whole-project and/or network-bound, so never a correctness gate. Aggregated bynpm 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 bynpm 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.
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
.tsextensions, never.js.node --strip-typesonly resolves the.tsform at test time; "pre-fixing" an import to.jsbreaks the inner loop. (why: development/tooling.md) - A new
npm runscript must reuse an existing prefix. See Script prefix convention. oxlint-disabledirectives 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.jsonto silence a finding (e.g.typescript/no-floating-promises) — see AGENTS.md § Never do. (why: development/tooling.md)- Don't put slow / network / whole-project scans in
checkor pre-commit. Advisory scans are not correctness gates; they belong undermaintain:. (why: development/workflow.md) - New work starts with
npm run create:branch, never a hand-writtengit switch -c/git checkout -b. The command carries the branch precondition; branching around it skips the clean-tree, current-mainand green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (why: development/workflow.md) - Work is merged back with
npm run create:finish, never a hand-writtengit merge. The command carries the merge-side preconditions (clean tree, currentmain, afeature//fix//chore/branch) and runsnpm run verifyafter the merge, so a merge cannot land unverified. (why: development/workflow.md) - There is no local
npm run publish, andpublish:publint/publish:attwdon't go incheck. (why: development/publishing.md) - A branch ends with a changelog note: before
npm run create:finish, summarize the work under[Unreleased]in CHANGELOG.md. (why: development/workflow.md) - A decision or its rationale belongs in
development/, not here. This file holds the actionable rule;development/<category>.mdholds 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) - 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)
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/<desc>/fix/<desc>/chore/<desc> - Starting work:
npm run create:branch -- <prefix>/<desc>. It refuses, without changing anything, unless the working tree is clean, no merge/rebase/cherry-pick is in progress,mainis not behind its upstream (a local merge not yet pushed is fine — the push belongs tocreate:release), andnpm run testis green onmain. 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, runsnpm run verify, and deletes the branch only after the merge is green. The push is left tocreate:release, so the merge stays local and reviewable. - CI runs on every push to
main— see Feedback tiers and .gitea/workflows/ci.yml. - Releases are NOT triggered by pushes. Only the maintainer triggers a release; see Publishing.
Full rationale, including the front-door decisions: 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:
npm run create:branch -- <prefix>/<desc>.- Commit your work (one or more commits, per the tests and style rules above).
npm run verify— the definition of done.- Add a changelog note under
[Unreleased](see Rules the tools don't enforce). npm run create:finishto merge the branch intomainand verify the result.- 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.