Files
tmu ebe58a9167 📝 Say compat is CI-only and run by hand locally
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`.
2026-09-29 21:15:01 +00:00

16 KiB
Raw Permalink Blame History

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

  1. Clone the repository.
  2. Install Node.js >= 26 — see .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
  • Compat (CI-only, manual locally): npm run test:compat — type-check the suite + a consumer fixture against the minimum supported TypeScript; needs a built dist/ and network access (npx). Never part of a hook or check/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 the ts-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:

  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). 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:branch opens a unit of work, create:finish closes the branch half, create:release closes the release half (maintainer-only), and create:doc-tests regenerates the compiled prose examples. 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:coverage runs c8 over the hand-written tests only; test:doc runs the generated doc examples without coverage; test:ci chains the two and fails below 100% coverage on src/; test:compat type-checks the suite + a consumer fixture against the minimum supported TypeScript via npx (both CI-only; verify stays coverage- and network-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.

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)
  • A new npm run script must reuse an existing prefix. See 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. (why: development/tooling.md)
  • 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)
  • 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)
  • 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)
  • There is no local npm run publish, and publish:publint / publish:attw don't go in check. (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>.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)
  • 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, 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 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:

  1. npm run create:branch -- <prefix>/<desc>.
  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).
  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.