`npm run check` should be the fast, offline correctness ladder only (tsc + oxlint + oxfmt + cspell, ~3s), so an agent can run it as a confirmation gate during feature work. check:knip / check:outdated were advisory whole-project / network scans, and check-outdated exits non-zero whenever any dep is behind. Keeping them in check made `npm run check` (and the CI build gate) fail on dependency freshness, which must not block an unrelated feature PR. Rename them to maintain:knip / maintain:outdated, aggregate under `npm run maintain`, and run it in CI as a dedicated non-blocking job (continue-on-error) that surfaces findings without ever gating a merge. Update the script-prefix convention, the feedback-tier table, and AGENTS.md: the agent may now run `npm run check`; only `maintain` stays out of the feature loop.
9.1 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.maintain:knip(~4s, whole project) andmaintain:outdated(~6.5s, registry) are advisory, not correctness — they live under themaintain:prefix and run in CI as a non-blocking job, never gating a merge. (see Why these splits and Script prefix convention) - There is no local
npm run publish, andpublish:publint/publish:attwdon't go incheck. They validate the publishable artifact (dist/) and run only in the CIpublishjob. (see Publishing workflow)
Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is commit-message-template; npm run use:git-commit-message installs it into .git/COMMIT_EDITMSG.
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. Aggregated bynpm run check. Used in pre-commit hooks (on staged files) and CI's build job (on the whole project). Read-only; never modifies files.fix:*— mutating counterpart of acheck:*script. Aggregated bynpm run fix. Use afternpm run checkto auto-resolve issues; the diff is the review surface.test:*— test scripts.npm run testis the canonical entry point (check:tsc+ unit tests);test:unitskips the typecheck for fast local iteration;test:ciadds c8 coverage and is the CI variant.watch:*— long-running watchers, started manually vianpm run watchfor the inner dev loop. Sits "before" pre-commit in the feedback ladder (see Feedback tiers below). Currently a single child (watch:test); futurewatch:oxlint/watch:tscwould aggregate under the samewatchumbrella.maintain:*— advisory repo-maintenance scans (dead code, dependency freshness): read-only, but whole-project and/or network-bound, so they are not correctness gates. Aggregated bynpm run maintain. Run on an explicit maintenance / update-deps branch, and in CI as a non-blocking job (continue-on-error) that surfaces findings without ever failing a feature PR.publish:*— runs only at publish time, in the CIpublishjob (immediately beforenpm publish). There is no localnpm run publishscript — publishing is CI-only by policy. Thepublish:prefix still documents intent: this script validates the publishable artifact (e.g.,dist/) rather than the source.
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.
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 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. The umbrellawatchscript is designed to aggregate multiplewatch:*children (currently justwatch:test); if more watchers are added later (e.g.watch:oxlint), the umbrella would switch to running them concurrently rather than sequentially.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.maintain:knipandmaintain:outdatedare advisory, not correctness — knip scans the whole project (~4s) andcheck-outdatedqueries the npm registry (~6.5s, network-dependent, and it exits non-zero whenever any dep is behind). That's why they live undermaintain:, are excluded from pre-commit and fromcheck, and run in CI as a non-blocking job: a stale dependency must never block an unrelated feature PR.publish:publintandpublish:attware NOT incheck— they belong to thepublish:prefix because they validate the publishable artifact (dist/) and require a fresh build. Running them on every commit would be wasteful. They run in the CIpublishjob immediately beforenpm publish.
Before pushing
Run npm run check && npm run test locally — both are the fast, offline correctness gates, safe to run any time. Anything maintain:knip or maintain:outdated would catch is reported by the non-blocking CI maintain job; run npm run maintain yourself only on a maintenance / update-deps branch.
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.