♻️ Single-home the actionable rules and the rationale

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.
This commit is contained in:
tmu committed 2026-09-15 13:46:31 +00:00
1 parent fe02317fc8
commit 95d73d11b6
5 files changed
+89 -126

No files matched your search

+3
View File
@@ -10,6 +10,9 @@ rules: read the relevant file here before changing an area, and update it when
a decision changes, rather than leaving an obsolete reason behind. A rule and
its reason drift apart when they live in one place and are maintained in two;
keeping the rule in CONTRIBUTING.md and the reason here keeps each single-homed.
**Each fact is written once**: the actionable rule in CONTRIBUTING.md, the
reason here. Neither restates the other — when the same fact would be useful in
both, one links to the other instead of copying it.
User-facing documentation lives in [README.md](../README.md). A `docs/` folder
is deliberately not used yet: the convention is that `docs/` is the future
+10 -31
View File
@@ -8,26 +8,9 @@ the way it is.
## Type-driven development
New behavior follows **type-driven development** (in Edwin Brady's sense):
_treat the type as the plan for a program, and use the compiler and type checker
as your assistant, guiding you to a complete program that satisfies the type_
([idris-lang.org](https://www.idris-lang.org/)). Here that plan is the
`expectTypeOf` assertion, written first. 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.
This is why every test in the suite pairs an `expectTypeOf(...)` with an
`assert.*` — keep them together.
The rules — the loop and the pairing rule — are in
[CONTRIBUTING.md § Testing discipline (type-driven)](../CONTRIBUTING.md#testing-discipline-type-driven).
What follows is why the loop is type-driven and what was rejected.
#### Decision (2026-09)
@@ -56,18 +39,14 @@ Per [AGENTS.md § Never do](../AGENTS.md#never-do), reach green honestly — fix
types so both the type check and the runtime assertion pass, never suppress the
ones you can't make pass.
## Test tiers
The runner is `node --test --strip-types "src/**/*.test.ts"` and the tiers are
listed in
[CONTRIBUTING.md § Development commands](../CONTRIBUTING.md#development-commands).
`c8` uses V8 coverage, so the `--strip-types` source is instrumented without a
build step. The runner relies on the `.ts` import-extension convention (see
[tooling.md](./tooling.md#source-imports-use-ts-extensions)).
- `npm run test:unit` — the test runner alone, for the fast local loop.
- `npm test` — `check:tsc` + the unit suite; this is what pre-push and the
baseline check run.
- `npm run test:ci` — adds c8 coverage; used by CI. `c8` uses V8 coverage, so
the `--strip-types` source is instrumented without a build step.
The runner is `node --test --strip-types "src/**/*.test.ts"`. It relies on the
`.ts` import-extension convention (see [tooling.md](./tooling.md#source-imports-use-ts-extensions)).
#### Known issue
## Known issues
- The type-aware linter misidentifies `expectTypeOf()` as a floating promise,
so `src/index.test.ts` carries a file-level `oxlint-disable
+19 -79
View File
@@ -13,28 +13,9 @@ request workflow on Gitea yet: collaborative review through the Gitea UI is not
in place, so Gitea is the lab. When something is tested and ready for
production it will be promoted to GitHub.
- **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 (untracked files
included), no merge/rebase/cherry-pick is in progress, `main` matches its
upstream, and `npm run test` is green on `main` — so a later failure is always
attributable to your edits. The prefix is still _your_ call, inferred from the
task; the script validates it rather than guessing it.
- **Merging:** `npm run create:finish` (on the branch). It asserts the same
clean-tree / no-operation / current-`main` preconditions, fast-forwards a stale
`main` (a true divergence is refused), merges the branch `--no-ff`, runs
`npm run verify`, and deletes the branch only after the merge is green. The
push is deliberately left to `create:release`, so the merge stays local and
reviewable — read the diff yourself before finishing.
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is
the authoritative gate. The one exception: a push headed by a release commit
(`:rocket: Release x.y.z`) skips the full `build`/`maintain` jobs, because
`create:release` pushes the tag for that exact commit right after and the tag
run is the authoritative one (see `release-gate` in
[.gitea/workflows/ci.yml](../.gitea/workflows/ci.yml)).
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a
release (see [publishing.md](./publishing.md)).
The contributor-facing steps are in
[CONTRIBUTING.md § Branching model](../CONTRIBUTING.md#branching-model); what
follows is why the front doors exist and what was rejected.
#### Decision (2026-09)
@@ -79,51 +60,18 @@ prose plus hand-written `git` commands.
## 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:
The prefix taxonomy is the rule, and it lives in
[CONTRIBUTING.md § Script prefix convention](../CONTRIBUTING.md#script-prefix-convention).
What follows is the design rationale and the `create:` decision.
- `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 — see `publish:*`
for the precedent.
- `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`; currently a single child (`watch:test`), and a future
`watch:oxlint` / `watch:tsc` would 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 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 (git config, editor settings) rather than the repo source, so it
is never part of a hook or CI step. Aggregated by `npm run setup` (the
umbrella), run once after cloning.
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. When it genuinely does belong, a new prefix is allowed —
but it enters both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in the same
commit as its first member, otherwise the rule "reuse an existing prefix"
silently develops an exception.
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`, `setup`), plus one convenience that
composes across tiers: `verify` — 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.
One part of the taxonomy is not a prefix rule: 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`, `setup`), plus one convenience that composes across tiers: `verify`
— 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.
#### Decision (2026-09)
@@ -155,11 +103,9 @@ aggregator.
## 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". The tier table itself
lives in [CONTRIBUTING.md](../CONTRIBUTING.md#feedback-tiers); this section
explains why the split is where it is.
The tier table and the rules for invoking it are in
[CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers); this
section explains why the split is where it is.
#### Decision (2026-09)
@@ -193,16 +139,10 @@ into pre-push and `verify`; and slow or network-bound scans into `maintain`.
tier; `verify` is run by hand because the push is where the whole project is
already checked.
Before pushing, run `npm run verify` — the one-shot correctness gate. Run
`npm run maintain` only on a maintenance / update-deps branch.
## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is
[commit-message-template](../commit-message-template); run
`npm run setup:git-commit-message` once after cloning to register it as git's
`commit.template` (or `npm run setup` to run every one-time clone step).
The convention is in
[CONTRIBUTING.md § Commit messages](../CONTRIBUTING.md#commit-messages).
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