Files
tiny-pattern-ts/development/workflow.md
T
tmu dc7ce22fc5 📝 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.
2026-09-15 13:26:59 +00:00

228 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)).