Adds the earliest feedback tier to the ladder: `npm run watch` re-runs tests on file save, started manually in a dedicated terminal pane. Built on Node 26's native `node --test --watch` (no new dependency). Structure follows the existing prefix convention: - `watch:test` — runs the test suite in watch mode - `watch` — umbrella that aggregates `watch:*` children (currently just `watch:test`; future `watch:oxlint` etc. would aggregate here, switching to concurrent execution) CONTRIBUTING.md documents the new tier in three places: the prefix convention list, the feedback tiers table (new top row), and a 'Why these splits?' bullet explaining why watch is a manual tier rather than a hook. README's Development section gains a one-line pointer.
5.6 KiB
Contributing
This document is for maintainers and contributors working on the project itself. End-user documentation is in README.md.
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.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 | Full project audit: all check:* scripts + knip + outdated |
~10s |
npm run fix |
manual | Auto-resolve fixable issues (lint, format) | ~3s |
| CI build (auto) | on push/PR | npm run check + npm run test:ci |
~30s+ |
| 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.check:knipandcheck:outdatedare NOT in pre-commit — knip scans the whole project (~4s, would noticeably slow the hook), andcheck-outdatedqueries the npm registry (~6.5s, network-dependent, advisory not correctness). Both run innpm run checkand CI; pre-commit stays fast and offline.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 locally. If you only trust pre-commit + CI, know that anything check:knip or check:outdated would catch will be caught by CI on the PR before merge.
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.