development/ had restated the actionables that CONTRIBUTING.md owns: the branching step list, the script prefix list, the feedback-tier rule of thumb, the commit convention and the type-driven test loop. Those now live only in CONTRIBUTING.md; development/ keeps the decision blocks and links to the rule. development/README.md, CONTRIBUTING.md and AGENTS.md state the 'write each fact once' principle explicitly.
219 lines
13 KiB
Markdown
219 lines
13 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 install`.
|
|
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`
|
|
- **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.
|
|
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). (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 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))
|
|
|
|
## 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` matches its upstream, 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 and a known issue about
|
|
`main` being ahead of its upstream between a merge and the next push:
|
|
[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. `npm run create:finish` to merge the branch into `main` and verify the
|
|
result.
|
|
5. 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).
|