📝 Add development/ docs for decisions and known issues
Category files under development/ replace the decision prose that was scattered through README.md and CONTRIBUTING.md. Each decision is a block with Decision (YYYY-MM) / Why / Rejected / Known issue; the new library.md records the public API contract and its limitations. AGENTS.md and backlog.tasks now point at the new home.
This commit is contained in:
1 parent
97dfe9e7b4
commit
dc7ce22fc5
9 files changed
+1049
-5
No files matched your search
@@ -0,0 +1,227 @@
|
||||
# Workflow
|
||||
|
||||
How work moves through the repository: the branching model, the `npm run`
|
||||
script taxonomy, the feedback tiers, and commit messages. The contributor-facing
|
||||
steps are in [CONTRIBUTING.md](../CONTRIBUTING.md); this file records why they
|
||||
are shaped the way they are.
|
||||
|
||||
## 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: 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)).
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
Work is opened and closed by `create:branch` / `create:finish` rather than by
|
||||
prose plus hand-written `git` commands.
|
||||
|
||||
#### Why
|
||||
|
||||
- The branching model's preconditions were prose. Prose rots silently — a rule
|
||||
nobody checks is a suggestion. A script asserts, then acts, and the branch or
|
||||
merge only happens if the assertions passed.
|
||||
- The type-driven loop only produces trustworthy results if the baseline was
|
||||
green before the first edit. Cheap checks run first and `npm run test` last, so
|
||||
the expensive gate is not paid on a tree that was never eligible.
|
||||
- `create:finish` owns the merge-side preconditions and the post-merge
|
||||
`npm run verify`, so a merge cannot land unverified. The push stays with
|
||||
`create:release` so the merge remains local and reviewable until then.
|
||||
- Every check failure is non-mutating, except the baseline test which runs on
|
||||
`main` after switching there — a red `main` restores the branch you started on
|
||||
rather than stranding you on it. A merge conflict aborts and returns to the
|
||||
feature branch, so a failed finish never leaves a half-merged `main`.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- Hand-written `git switch -c` / `git merge`: same rules, no enforcement.
|
||||
- Reusing `pubv`'s preflight for `create:branch`: it is release-shaped and
|
||||
third-party, and would make starting a branch pay for a build and pack it has
|
||||
no use for.
|
||||
- Leaving the merge as reviewer judgment: that judgment still exists, but it now
|
||||
happens when the maintainer reviews the handover _before_ invoking
|
||||
`create:finish`, rather than being encoded in a command that anyone can run
|
||||
from a dirty tree.
|
||||
- `--no-ff` rather than a fast-forward: it keeps each unit of work visible in
|
||||
`git log`.
|
||||
|
||||
#### Known issue
|
||||
|
||||
- `create:finish` deliberately does not push, so `main` is ahead of
|
||||
`origin/main` between a merge and the next push or release. `create:branch`
|
||||
requires `main` to match its upstream and will refuse until it is pushed. Push
|
||||
`main` before starting the next branch.
|
||||
|
||||
## 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:
|
||||
|
||||
- `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.
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
`create:` is the prefix for workflow front doors, and there is no bare `create`
|
||||
aggregator.
|
||||
|
||||
#### Why
|
||||
|
||||
- Both members really do create something: a branch, a release.
|
||||
- The prefix was added to both lists in [CONTRIBUTING.md](../CONTRIBUTING.md) in
|
||||
the same commit as its first members, because a prefix missing from those
|
||||
lists is invisible — which was the `use:` mistake this repo previously
|
||||
carried (since retired into `setup:`).
|
||||
- `publish:*` already set the precedent for a prefix without an aggregator.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- `run:` / `perform:` — both mean only "do the thing named after them", so every
|
||||
script in the repo would fit under them and the taxonomy collapses.
|
||||
- `git:` — names the tool, not the lifecycle moment, and advertises passthrough
|
||||
aliases.
|
||||
- `start:` — describes the branch half, not the release.
|
||||
- `cut:` — idiomatic for both, but it needs VCS slang to decode, and a signpost
|
||||
that has to be explained is not one.
|
||||
- `flow:` — overloaded in a library about type-level matching.
|
||||
- The existing families: `check:*` is read-only and aggregated by `check`, so CI
|
||||
would run a command that mutates repo state; `fix:*`'s review surface is a file
|
||||
diff, not a branch; `maintain:*` is advisory and explicitly never a gate.
|
||||
|
||||
## 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.
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
Split fast, offline, staged-file checks into pre-commit; whole-project test runs
|
||||
into pre-push and `verify`; and slow or network-bound scans into `maintain`.
|
||||
|
||||
#### Why
|
||||
|
||||
- **`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 ladder, catching failures the moment a file is
|
||||
saved — before staging, before commit.
|
||||
- **`check:tsc`, `check:oxlint`, `check:oxfmt`, `check:cspell`** are in
|
||||
pre-commit because they are fast (~0.2–0.5s each), fully offline, and naturally
|
||||
scope to staged files via the `LEFTHOOK_FILES` env var convention. They give
|
||||
instant feedback on what you typed.
|
||||
- **`test` (and the `tsc` it includes) is in pre-push** because it runs the whole
|
||||
test suite across the whole project. The pre-commit `LEFTHOOK_FILES` convention
|
||||
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.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- Running `maintain:*` in `check` or pre-commit: advisory, whole-project and
|
||||
network-bound scans are not correctness gates and would make the fast tier
|
||||
slow.
|
||||
- Treating a green pre-commit as the definition of done: it only sees staged
|
||||
files, which is why `npm run verify` exists as the one-shot whole-project gate.
|
||||
- A separate `git push` hook for `verify`: it would duplicate the pre-push test
|
||||
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).
|
||||
|
||||
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 #...`.
|
||||
|
||||
#### Decision (2026-09)
|
||||
|
||||
Gitmoji subjects with imperative mood, wrapped 50/72, rather than Conventional
|
||||
Commits.
|
||||
|
||||
#### Why
|
||||
|
||||
- The history is gitmoji, not Conventional, and it predates any commit-lint
|
||||
tooling; switching would rewrite the convention for no gain.
|
||||
- The body carries reasoning, which is what a reviewer needs; the subject is a
|
||||
signpost, not a semantic key.
|
||||
|
||||
#### Rejected
|
||||
|
||||
- Conventional Commits: the release flow uses hand-written Keep-a-Changelog
|
||||
notes, not generated ones, so the prefix carries no automation value here (see
|
||||
[publishing.md](./publishing.md)).
|
||||
Reference in new issue
Block a user