New behavior is written type-first: the expectTypeOf (blue) comes before the assert (red), since for a pattern-matching library the types are the feature. Adds a CONTRIBUTING.md section and a discoverability pointer in AGENTS.md's "Read these" index. Human-facing rationale lives in CONTRIBUTING.md; AGENTS.md only links it so agents and reviewers don't diverge.
9.2 KiB
Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in README.md. The machine entry point for AI coding agents is AGENTS.md; keep this file as the prose home for the rules below so agents and humans don't diverge.
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-typesresolves the.tsform at test time;rewriteRelativeImportExtensionsemits.jsindist/. "Pre-fixing" an import to.jsbreaks the inner loop. (rationale: README § Tooling decisions) - A new
npm runscript must reuse an existing prefix (check:/fix:/test:/watch:/maintain:/publish:). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new 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 these — see AGENTS.md § Never do. (rationale: README § Tooling decisions)- Don't put slow / network / whole-project scans in
checkor pre-commit. Advisory scans are not correctness gates; they belong undermaintain:. (see Feedback tiers and Script prefix convention) - There is no local
npm run publish, andpublish:publint/publish:attwdon't go incheck. (see Publishing workflow)
Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is commit-message-template; run npm run use:git-commit-message once after cloning to register it as git's commit.template.
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 <prefix>:<name> script is implicitly aggregated by a <prefix> script (if one exists) and run by the corresponding lefthook hook or CI step. Picking the right prefix documents the script's intended lifecycle:
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:ciadds c8 coverage.watch:*— long-running watchers for the manual inner dev loop. Aggregated bywatch; currently a single child (watch:test), and a futurewatch:oxlint/watch:tscwould 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 bynpm 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 and Publishing workflow)
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.
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), plus verify — a cross-cutting convenience 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.
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/PR | npm run check + npm run test:ci |
~30s+ |
| CI maintain (auto, non-blocking) | on push/PR | npm run maintain — reports, never fails the build |
~10s |
| CI publish (auto) | on tag | publish:publint + publish:attw, then npm publish |
~10s |
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:cspellare in pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally scope to staged files via theLEFTHOOK_FILESenv var convention. They give instant feedback on what you typed.test(and thetscit includes) is in pre-push because it runs the whole test suite across the whole project. The pre-commitLEFTHOOK_FILESconvention 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.
Testing discipline (type-first)
For this library the types are the feature — narrowing, exhaustive() returns, the Matcher<T> contract — so a runtime-only test loop would verify the wrong thing. New behavior is written type-first, in a red/green/blue loop:
- Blue — 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.
This is why every test in the suite pairs an expectTypeOf(...) with an assert.* — keep them together. Blue-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, never reach green by suppressing the type system (@ts-ignore, as casts, expectTypeOf removed) — fix the types so both blue and red go green honestly.
Publishing workflow
Publishing is CI-only by policy. Local npm publish is not supported.
- Develop and merge PRs to
main. - CI runs
npm run check+npm run test:cion every push and PR — this is the authoritative gate. - After all intended changes are on
main, bump the version locally:npm version <patch|minor|major> - Push the tag to GitHub:
git push --follow-tags origin main - The
publishCI job runs on the tag:build→publish:publint→publish:attw→npm publish --access public. The publish-tier checks must pass before the artifact is published.