The matcher is now two three-overload factories; library.md documents why the union merge, inferred universe, conditional RequireKeys and cases-first paths were rejected, and lists the two open issues (the fallback sees all of T; a redundant _ is still accepted). testing.md records the language server as the autocomplete oracle, and CONTRIBUTING points the exception at the matcher's own test file. The backlog marks the design-doc and autocomplete groundwork done alongside the adoption.
243 lines
15 KiB
Markdown
243 lines
15 KiB
Markdown
# 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 + 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.
|
||
The autocomplete tests (`src/util/__tests__/lsp-completion.test.ts` for the
|
||
helper, `src/primitive.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 `<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 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` adds c8 coverage.
|
||
- `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 these — see
|
||
[AGENTS.md § Never do](./AGENTS.md#never-do). The sole agent exception is the
|
||
fixed file-level header at the top of a `*.test.ts` file, spelled exactly:
|
||
`/* oxlint-disable typescript/no-floating-promises -- expectTypeOf() is a sync
|
||
type-assertion library that the type-aware linter misidentifies as a promise
|
||
*/` — any other suppression stays human-last-resort. (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/<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](./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/<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](#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 -- <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](#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).
|