10 Commits
Author SHA1 Message Date
tmu 672fd1f7e4 ♻️ Adopt setup: prefix and retire the stray use: script
Introduce a setup: prefix for one-time clone configuration (mutates the
local environment, never a hook or CI step) with setup as its umbrella
aggregator. Move use:git-commit-message to setup:git-commit-message as
its first member, retiring use: — the undocumented prefix tracked in the
backlog. Update both CONTRIBUTING.md prefix lists, the bare-command
inventory, and the stale use: reference in branch.sh.
2026-09-08 23:01:49 +02:00
tmu 844e80d650 ♻️ Group workflow scripts under a create: prefix
`branch` and `release` were bare commands, but bare in this repo means
"how you invoke a tier" or "runs one tool" — neither fits a command that
opens or closes a unit of work. They get their own prefix now, since a
prefix is how this repo records _when_ a script runs.

`create:` because both members genuinely create something (a branch, a
release) and it is a plain verb rather than VCS slang. No bare `create`
aggregator: running "all the workflows" describes nothing anyone wants,
and `publish:*` already precedents a prefix without one.

The rule "reuse an existing prefix, never invent one" now says what it
actually means: a new prefix is allowed when the scripts belong in the
pipeline, provided it enters both lists in the same commit as its first
member. That is the lesson from `use:`, which is referenced by prose yet
in none of the lists — already tracked in backlog.tasks.

The bare-command sentence shrinks to `build`, `clean`, `verify`.
2026-09-08 22:51:35 +02:00
tmu 8c2855aa92 📝 Track the undocumented use: prefix in the backlog
`use:git-commit-message` is referenced by CONTRIBUTING.md but appears in
none of the lists that define the script vocabulary, so the rule "reuse
an existing prefix, never invent one" is currently violated by its own
exception.

Recorded as a task rather than fixed here: the prefix wants a name that
says what it is for, and that is the same decision as naming a prefix
for the other lifecycle scripts, so the two should be chosen together.
2026-09-08 22:32:05 +02:00
tmu 8efd13b2f4 ✨ Add branch front-door command
The branching model assumed a clean, current `main` and a green baseline
before any edit, but both were prose, and prose nobody checks silently
becomes a suggestion. `npm run branch -- <prefix>/<desc>` asserts the
precondition and only then creates the branch, so a later failure is
attributable to the change that caused it.

Checks run cheap-first (`--porcelain` deliberately catches untracked
files, which would otherwise ride onto the new branch) and the suite
runs last, so an ineligible tree never pays for it. `main`'s remote is
derived from its upstream rather than hardcoded: this repo has both
`origin` and `origin_https`, and `main` tracks the latter, so a
`git fetch origin main` currency check would compare against a ref that
is never updated here. The baseline runs after switching to `main`, so a
red `main` restores the starting branch instead of stranding the caller
on it.

The prefix stays a judgment call: the script validates it against the
documented vocabulary instead of inferring it.

Prose kept as index only — AGENTS.md points agents at the command from
the task workflow, CONTRIBUTING.md owns the model and the bare-script
tier (dropping its stale hardcoded count of "two" conveniences).
2026-09-08 22:14:34 +02:00
tmu 93cb37441d 📝 Document pi-lsp tooling and agent usage
README § Tooling and § Tooling decisions record the extension and the
reasoning behind it: read-only by design, auto-detects TypeScript 7,
and never a correctness gate — verify is. AGENTS.md § First action tells
agents the lsp_* tools exist, to prefer lsp_references over grep -w for
colliding identifiers, and to treat empty LSP output as inconclusive.

Close the backlog evaluation task with a resolved note, and allowlist
the tsgo/tsserver tool names for cspell.
2026-09-08 20:47:40 +02:00
tmu 863198d472 ✨ Adopt @spences10/pi-lsp project-local
Declare the read-only LSP extension in .pi/settings.json so it is
shared with the team and auto-installed on project trust.

Pin 0.0.46 explicitly: a bare `pi install` writes `^0.0.10`, and in
semver a caret on 0.0.x pins to exactly 0.0.10, which hard-wires
typescript-language-server and predates TypeScript 7. 0.0.46 detects
the repo's own typescript@7 (no lib/tsserver.js) and spawns
tsc --lsp --stdio against it — no extra server package required.

Commit pi's own .pi/npm/.gitignore sentinel rather than adding a rule
to the project root .gitignore: pi put the file there deliberately, so
leaving it in place (and tracked) is the least-surprise option for a
human reading the tree.
2026-09-08 20:47:22 +02:00
tmu 15aef06e32 🐛 Require @done on every ✔ backlog line
The sandy081.todotasks extension marks a line done from the ✔ glyph
alone, then unconditionally decorates its @done tag via
lineText.indexOf("@done"). On a bare ✔ that returns -1, so the
decorator builds a Range with a negative character offset, VS Code
throws, and all decoration/highlighting for the document dies.

The committed backlog.tasks contained two such lines (the branching-
model subtasks), so re-opening the file reproducibly broke
highlighting. Add @done to them and document the required ✔/@done
(and ✘/@cancelled) coupling in AGENTS.md.
2026-09-08 14:56:47 +02:00
tmu b50149aad6 📝 Document 'release' as a bare script in prefix convention
'release' (./scripts/release.sh) runs no tool and aggregates no
prefix:* family, so it fits neither a tool entry point nor a tier
aggregator. List it among the bare conveniences alongside 'verify'.
2026-09-08 14:07:36 +02:00
tmu 06bc6bc43e 📝 Document GitHub Flow branching model and agent task workflow
Adopt single-developer GitHub Flow: branches off main with a
feature/fix/chore prefix, merged back via 'git merge --no-ff' (local
PR). Releases are not triggered by pushes; only the maintainer runs
'npm run release', which tags and pushes; CI publishes to npm on the
tag. Gitea is the lab; GitHub is reserved for later promotion.

Replace the 'not yet settled' backlog note in AGENTS.md with concrete
agent instructions: a task with subtasks gets a branch, a leaf task is
worked on the current branch, and a fixed handover template (Implemented
/ Judgement calls / Known problems) frames the pre-merge review. Drop
the stale 'MR' reference in 'Never do' (no MR workflow). Update the
feedback-tier table and publishing workflow to gate CI on push to main,
not PRs. Check off the branching-model backlog group and open a task
for the release CI workflow (blocked on a missing Gitea runner).
2026-09-08 14:07:33 +02:00
tmu 75d605fabe 📝 Add backlog.tasks template and format rules
Introduce backlog.tasks, a project backlog in vscode-todotasks format
(not Markdown), seeded as a reusable template: Setup, v1.0, Bugs,
Enhancements, Documentation and Maintenance projects, with the
implementation tasks to be filled in later.

Document the format in AGENTS.md so agents can read and update the file,
recommend sandy081.todotasks in .vscode/extensions.json, and whitelist
"todotasks" for cspell, which both new mentions otherwise fail.

The branch, review and release policy is deliberately not asserted here:
it is unsettled, so it is tracked as a task-group under Setup and AGENTS.md
tells agents to take branch and merge instructions from the user until that
work is documented.
2026-09-08 11:42:46 +02:00
11 changed files with 271 additions and 24 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+3
View File
@@ -0,0 +1,3 @@
{
"packages": ["npm:@spences10/pi-lsp@0.0.46"]
}
+2 -1
View File
@@ -2,6 +2,7 @@
"recommendations": [ "recommendations": [
"oxc.oxc-vscode", "oxc.oxc-vscode",
"streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker",
"typescriptteam.native-preview" "typescriptteam.native-preview",
"sandy081.todotasks"
] ]
} }
+26 -1
View File
@@ -9,6 +9,7 @@ first-action facts. Do not restate evolving prose here — it will drift.
- Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim). - Project: F#-style pattern matching for TypeScript/ESM. Node `>=26` (pinned via `.node-version`), ESM-only (no CommonJS shim).
- **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed.
- **Optional code intelligence:** this repo installs `@spences10/pi-lsp` (pinned in `.pi/settings.json`) as a project-local pi extension. It talks to the repo's own TypeScript 7 via `tsc --lsp --stdio` and exposes **read-only** tools — `lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_find_symbol`, `lsp_document_symbols`, `lsp_diagnostics(_many)`. Prefer `lsp_references` over `grep -w` for widely-colliding identifiers (`matches`, `type`, …); use `lsp_hover` to read inferred types on generic-heavy code. It has no rename / code-action / apply-edit surface — the write side is pi's `edit` tool + `check:tsc`. Treat empty LSP output as _inconclusive_, not success: **`npm run test` / `npm run verify` remain the sole authoritative gate** (see the next bullet).
- **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run. - **Definition of done — run this before you call the work finished:** `npm run verify`. If all green, commit. If red, look at the output, fix the root cause, and re-run.
- **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit. - **On commit:** write a good message (see [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages)). Lefthook's pre-commit hook already runs the fast, offline, staged-file checks — don't run them by hand. If the hook fails on style, `npm run fix`, restage, recommit.
- **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch. - **`npm run maintain` is NOT part of the feature loop.** Its scans are advisory, never a gate; run them only on an explicit maintenance / update-deps branch.
@@ -26,12 +27,36 @@ Don't silence the type system to force a green run. As an agent these are forbid
- `// oxlint-disable` / `// oxlint-disable-next-line` - `// oxlint-disable` / `// oxlint-disable-next-line`
- `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions) - `as` casts used to push an expression through (type-aware oxlint already flags unsafe assertions)
Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / MR) rather than suppress it. Fix the root cause with the type system instead — narrowing, generics, `satisfies`, conditional / mapped types, utility types (`NonNullable`, `Exclude`, …). TypeScript can express it; that's the intended tool. The `oxlint-disable`-location rule in [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) is a **human** last-resort convention (so a reviewer can spot a deliberate suppression) — it is not permission for you to add one. If the types genuinely cannot express something, stop and surface the conflict (commit message / handover) rather than suppress it.
The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`. The same applies to the checks themselves: **never `git commit --no-verify`** (or otherwise skip a pre-commit / pre-push hook). The checks are fast and offline, so a redundant run is fine — bypassing a hook to get green is the identical anti-pattern. If a commit already skipped a hook, redo it through one: `git reset --soft HEAD~1 && git commit -C <skipped-sha>`.
Never start a long-lived / blocking process such as `npm run watch`. It runs until a human stops it with Ctrl-C, so in an agent turn it hangs forever and floods the context with continuous output. Reach for a one-shot command instead — `npm run test` (or `npm run check`) — to get feedback. Never start a long-lived / blocking process such as `npm run watch`. It runs until a human stops it with Ctrl-C, so in an agent turn it hangs forever and floods the context with continuous output. Reach for a one-shot command instead — `npm run test` (or `npm run check`) — to get feedback.
## Backlog
`backlog.tasks` uses the vscode-todotasks format (not Markdown). A line ending in `:` is a project; every other line is a task. Status glyphs: `☐` open, `✔` done, `✘` cancelled; subtasks nest by indentation. Inline `@tags` carry metadata — `@done` / `@cancelled` mark completion, `@critical` / `@high` / `@low` / `@today` set priority. The `(…)` timestamp after `@done` is editor-generated: omit it when checking off by hand.
**Required coupling:** a `✔` line _must_ also carry `@done`, and a `✘` line _must_ carry `@cancelled`. The `sandy081.todotasks` extension treats the glyph as the completion signal, then unconditionally searches for the matching tag to decorate; a bare `✔`/`✘` with no tag makes it compute an illegal `Range` (negative character offset) that throws and kills all highlighting/decoration for the document. A `☐` may stand alone. So check off by hand as `✔ … @done` (optionally `@done (timestamp)`), never a lone `✔`.
### Working on tasks
- **Task with subtasks** (a task that has indented children): create the branch with `npm run create:branch -- <prefix>/<desc>`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape:
```md
## Handover — <branch>
**Implemented:** <what was built, and how>
**Judgement calls:** <where the task was unclear, and what you assumed>
**Known problems:** <open issues, caveats, follow-ups>
```
Once the user has no further objections, merge back: `git checkout main && git merge --no-ff <branch>`. The branching model is documented in [CONTRIBUTING.md § Branching model](./CONTRIBUTING.md#branching-model).
- **Leaf task** (no indented children): implement on the current branch and commit.
In both cases, follow [CONTRIBUTING.md § Testing discipline (type-driven)](./CONTRIBUTING.md#testing-discipline-type-driven). Each subtask gets one or more commits.
## Read these ## Read these
- [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) — the constraints the linters don't catch; CI/review bounce these. **The most important section.** - [CONTRIBUTING.md § Rules the tools don't enforce](./CONTRIBUTING.md#rules-the-tools-dont-enforce) — the constraints the linters don't catch; CI/review bounce these. **The most important section.**
+25 -18
View File
@@ -7,14 +7,15 @@ This document is for maintainers and contributors working on the project itself.
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: 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 `.ts` extensions, never `.js`.** `node --strip-types` resolves the `.ts` form at test time; `rewriteRelativeImportExtensions` emits `.js` in `dist/`. "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions) - **Source imports use `.ts` extensions, never `.js`.** `node --strip-types` resolves the `.ts` form at test time; `rewriteRelativeImportExtensions` emits `.js` in `dist/`. "Pre-fixing" an import to `.js` breaks the inner loop. (rationale: README § Tooling decisions)
- **A new `npm run` script 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](#script-prefix-convention)) - **A new `npm run` script must reuse an existing prefix** (`create:` / `check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:` / `setup:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. If it genuinely does belong, add the prefix to both lists here in the same commit; an undocumented prefix becomes invisible and quietly accrues members. (see [Script prefix convention](#script-prefix-convention))
- **`oxlint-disable` directives 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](./AGENTS.md#never-do). (rationale: README § Tooling decisions) - **`oxlint-disable` directives 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](./AGENTS.md#never-do). (rationale: README § Tooling decisions)
- **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention)) - **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention))
- **New work starts with `npm run create:branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model))
- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) - **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow))
## Commit messages ## Commit messages
Gitmoji subject, imperative mood, 50/72 wrapping. The template is `commit-message-template`; run `npm run use:git-commit-message` once after cloning to register it as git's `commit.template`. Gitmoji subject, imperative mood, 50/72 wrapping. The template is `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 #...`. 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 #...`.
@@ -22,16 +23,18 @@ Examples from history: `:sparkles: Add watch tier with watch:test child`, `:recy
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: 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 (asserts a clean tree, a current `main` and a green baseline before it branches), `create:release` closes one (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`. - `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. - `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. - `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. - `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`. - `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. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow)) - `publish:*` — validates the _publishable artifact_ (e.g. `dist/`) rather than the source, so it needs a fresh build. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow))
- `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. 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 this file in the same commit as its first member, otherwise the rule "reuse an existing prefix" silently develops an exception (the old `use:` prefix was exactly that; it is now retired into `setup:`).
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`), plus `verify` — a cross-cutting convenience 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. 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.
## Feedback tiers ## Feedback tiers
@@ -46,8 +49,8 @@ The tools are organized into a feedback ladder. Each tier catches different thin
| `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s | | `npm run verify` | manual | Definition of done: `npm run check` + unit tests, one shot | ~6s |
| `npm run fix` | manual | Auto-resolve fixable issues (lint, format) | ~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 | | `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 build (auto) | on push to `main` | `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 maintain (auto, non-blocking) | on push to `main` | `npm run maintain` — reports, never fails the build | ~10s |
| CI publish (auto) | on tag | `publish:publint` + `publish:attw`, then `npm publish` | ~10s | | CI publish (auto) | on tag | `publish:publint` + `publish:attw`, then `npm publish` | ~10s |
### Why these splits? ### Why these splits?
@@ -71,18 +74,22 @@ For this library the types _are_ the feature — narrowing, `exhaustive()` retur
This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass. This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert.*` — keep them together. Type-first is also enforced structurally: `npm test` runs `check:tsc` before the test runner, so a wrong type can never be papered over by a passing assertion. Per [AGENTS.md § Never do](./AGENTS.md#never-do), reach green honestly — fix the types so both the type check and the runtime assertion pass, never suppress the ones you can't make pass.
## Branching model
**GitHub Flow (single-developer).** Every change — feature, fix, refactor — branches off `main` and is merged back via a local commit (no PR workflow on Gitea yet). Collaborative review via Gitea UI is not in place — 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:** `git checkout main && git merge --no-ff <branch>` (local PR — review the diff yourself before closing the branch).
- CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate.
- **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)).
## Publishing workflow ## Publishing workflow
Publishing is CI-only by policy. Local `npm publish` is not supported. Publishing is CI-only by policy. Local `npm publish` is not supported. The maintainer triggers releases from `main`:
1. Develop and merge PRs to `main`. 1. All intended changes are merged to `main` and passing CI.
2. CI runs `npm run check` + `npm run test:ci` on every push and PR — this is the authoritative gate. 2. The maintainer runs `npm run create:release` — an interactive prompt suggests a version (based on the latest CHANGELOG entry); the maintainer confirms or edits it.
3. After all intended changes are on `main`, bump the version locally: 3. `scripts/release.sh` creates a single release commit (changelog + package.json bump, amended into one commit), tags it, and pushes everything to Gitea.
```sh 4. CI runs on the push: `npm run check` + `npm run test:ci` on the commit; then the `publish` job fires on the tag: `build` → `publish:publint` → `publish:attw` → `npm publish --access public`. The publish-tier checks must pass before the artifact is published.
npm version <patch|minor|major>
```
4. Push the tag to the forge (Gitea):
```sh
git push --follow-tags origin main
```
5. The `publish` CI 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.
+2
View File
@@ -30,6 +30,7 @@ What each tier runs, when it fires and what it costs:
- **publint** — validates `package.json` for ESM publishing correctness. - **publint** — validates `package.json` for ESM publishing correctness.
- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. - **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios.
- **lefthook** — git hooks. - **lefthook** — git hooks.
- **@spences10/pi-lsp** — read-only LSP code intelligence for AI coding agents (project-local `.pi/settings.json`). Talks to this repo's TypeScript 7 via `tsc --lsp --stdio`.
Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). Each tool's configuration trade-off is recorded in [Tooling decisions](#tooling-decisions); when it runs is in [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers).
@@ -46,6 +47,7 @@ The choice and configuration of each tool above is the result of deliberate trad
- **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end). - **`check:tsc` runs first** in the `npm run check` chain so a type error short-circuits the rest (faster feedback than letting oxlint/oxfmt run and then failing on tsc at the end).
- **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_. - **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-<default>}` to default to the whole project when invoked manually. This keeps `package.json#scripts` as the single source of truth for the underlying commands — `lefthook.yml` only describes _what to run on which files_.
- **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both. - **`tslib` and `type-fest` are deliberately not used.** `tslib` is a runtime helper for old ES3/ES5 targets (the project targets ES2024); `type-fest` was never imported. knip caught both.
- **`@spences10/pi-lsp` is pinned to `0.0.46` and is read-only by design.** The package inspects `node_modules/typescript`, sees major ≥ 7 with no `lib/tsserver.js` (true of the `typescript-go` / `tsgo` port), and spawns the repo's own `tsc --lsp --stdio` binary — no `typescript-language-server` dependency is required. Earlier releases (`≤ 0.0.10`) hard-wire to `typescript-language-server --stdio` and are TS6-only. The tool is _intermediate_ agent feedback (hover, references, definition, symbols, diagnostics); it has no rename / code-action / apply-edit surface, and never a correctness gate — `npm run check` / `verify` remain that.
### Requirements ### Requirements
+54
View File
@@ -0,0 +1,54 @@
Tasks
Backlog and tracking for tiny-pattern-ts. Managed in vscode-todotasks format.
---
Setup:
☐ Establish release CI workflow: post-tag checks + npm publish after release tag push => pi --session 01a06e72-e61e-7327-b3e9-3e11749a523f @high
☐ Add gitea release page in CI @high
☐ Split off template into separate package => pi --session 01a07dde-7050-7054-bb36-1606d7eb2bc3 @high
✔ Document branching model @done
✔ Describe branching model: when to branch, base branch, naming @done
✔ Clarify release workflow: who pushes `main`, CI as post-merge gate, drop PR usage @done (9/8/2026, 2:54:32 PM)
✔ Evaluate connecting the agent to lsp typescript server @done
- might be more difficult with TS7, since LSP Server has changed from 6
- but since vscode is running TS7, maybe it is possible to connect to the TS7 LSP server of vscode
- LSP servers in general?
- maybe skills?
→ resolved: adopted @spences10/pi-lsp@0.0.46 as a project-local pi extension (`.pi/settings.json`). It auto-detects the repo's TypeScript 7 (no `lib/tsserver.js`) and spawns `tsc --lsp --stdio` — the same tsgo binary VS Code's native-preview uses. Read-only: hover / definition / references / symbols / diagnostics. No skill; skills don't own persistent processes.
✔ Adopt a `setup:` prefix for one-time clone configuration @done
✔ Register `use:git-commit-message` as its first member (and retire `use:` — it is in no prefix list) @done
✔ Create an umbrella 'setup' task that runs all setup tasks - currently one @done
v1.0:
☐ API surface is stable and fully typed
☐ Finalize public exports in `src/index.ts`
☐ Document all exported types and functions
☐ Add JSDoc for public APIs
☐ Test coverage meets threshold
☐ Achieve 100% branch coverage on `src/pattern.ts`
☐ Achieve 100% branch coverage on `src/match.ts`
☐ Achieve 100% branch coverage on `src/index.ts`
☐ `dist/` output is clean
☐ Verify `.d.ts` declarations match public exports
☐ Ensure `.js` files use `.js` extensions (not `.ts`)
☐ Test `attw --profile esm-only` passes
Bugs:
Enhancements:
Documentation:
☐ Add usage examples to README.md
☐ Create `examples/` directory with runnable snippets
☐ Add comparison section vs. other TS pattern-matching libs
☐ Write migration guide for users coming from discriminated unions
☐ Create backlog tasks for implementation
Maintenance:
✔ Set up lefthook pre-commit hook @done (9/8/2026, 10:52:56 AM)
✔ Configure tsconfig strictest + node26 profiles @done (9/8/2026, 10:08:37 AM)
✔ Add check:tsc as first tier in `npm run check` @done (9/8/2026, 10:08:37 AM)
✔ Pin Node.js >= 26 via `.node-version` @done (9/8/2026, 10:08:38 AM)
+4 -1
View File
@@ -19,6 +19,8 @@
"arethetypeswrong", "arethetypeswrong",
"knip", "knip",
"tsgolint", "tsgolint",
"tsgo",
"tsserver",
"gitea", "gitea",
"pubv", "pubv",
"knope", "knope",
@@ -29,7 +31,8 @@
"kacl", "kacl",
"bestikk", "bestikk",
"silverwind", "silverwind",
"idris" "idris",
"todotasks"
], ],
"ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"] "ignorePaths": ["dist", "node_modules", "coverage", "*.svg", ".gitignore"]
} }
+4 -2
View File
@@ -49,7 +49,8 @@
"fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix": "npm run fix:oxlint && npm run fix:oxfmt",
"fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}",
"fix:oxlint": "oxlint --fix src", "fix:oxlint": "oxlint --fix src",
"release": "./scripts/release.sh", "create:branch": "./scripts/branch.sh",
"create:release": "./scripts/release.sh",
"maintain": "npm run maintain:knip; npm run maintain:outdated", "maintain": "npm run maintain:knip; npm run maintain:outdated",
"maintain:knip": "knip --include dependencies,exports,files", "maintain:knip": "knip --include dependencies,exports,files",
"maintain:outdated": "check-outdated --ignore-pre-releases", "maintain:outdated": "check-outdated --ignore-pre-releases",
@@ -61,7 +62,8 @@
"watch:test": "node --test --watch --strip-types \"src/**/*.test.ts\"", "watch:test": "node --test --watch --strip-types \"src/**/*.test.ts\"",
"publish:attw": "attw . --pack --profile esm-only", "publish:attw": "attw . --pack --profile esm-only",
"publish:publint": "publint", "publish:publint": "publint",
"use:git-commit-message": "git config commit.template commit-message-template" "setup": "npm run setup:git-commit-message",
"setup:git-commit-message": "git config commit.template commit-message-template"
}, },
"devDependencies": { "devDependencies": {
"@arethetypeswrong/cli": "^0.18.5", "@arethetypeswrong/cli": "^0.18.5",
+148
View File
@@ -0,0 +1,148 @@
#!/bin/sh
set -eu
# Branch front-door. Run as `npm run create:branch -- <prefix>/<desc>`.
#
# How we got here (short): the branching model says every change starts from a
# clean, current `main`, and the type-driven loop only produces trustworthy
# results if the baseline was green *before* the first edit. Both facts were
# prose. Prose rots silently — a rule nobody checks is a suggestion — so the
# precondition became this script: it asserts, then branches, and the branch
# only appears if the assertions passed. Cheap checks run first, `npm run test`
# runs last: the expensive gate is not paid on a tree that was never eligible.
#
# Rejected for the prefix name: `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 this 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); and 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.
#
# `create:` was kept because both members really do create something: a branch,
# a release. It was added to both prefix lists in 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 carried in backlog.tasks (since retired into `setup:`).
# There is deliberately no bare `create` aggregator: "run all the workflows"
# describes nothing anyone wants, and `publish:*` already sets the precedent for
# a prefix without one.
#
# Also rejected: a full git-flow CLI wrapping the merge too (merging ends in
# "review the diff yourself", which is judgment, and only the start half carries
# a verification burden); and reusing `pubv`'s preflight (release-shaped,
# third-party, and it would make branch start pay a build + pack it has no use
# for).
#
# Every refusal is non-mutating except the baseline test, which runs on `main`
# after we switch there — so a red `main` restores the branch you started on
# rather than stranding you on it.
BASE="main"
PREFIXES="feature fix chore"
NAME="${1:-}"
if [ -z "${NAME}" ]; then
echo "usage: npm run create:branch -- <prefix>/<desc> (prefix: ${PREFIXES})" >&2
exit 2
fi
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
echo "error: not inside a git work tree." >&2
exit 1
}
MATCH=0
for p in ${PREFIXES}; do
case "${NAME}" in
"${p}/"*) MATCH=1 ;;
esac
done
if [ "${MATCH}" -ne 1 ]; then
echo "error: '${NAME}' must start with one of: ${PREFIXES}." >&2
echo " the prefix is inferred from the task, not defaulted here." >&2
exit 1
fi
git check-ref-format --branch "${NAME}" >/dev/null 2>&1 || {
echo "error: '${NAME}' is not a valid branch name." >&2
exit 1
}
git show-ref --verify --quiet "refs/heads/${NAME}" && {
echo "error: branch '${NAME}' already exists; switch to it instead." >&2
exit 1
}
START_REF=$(git symbolic-ref --quiet --short HEAD || true)
if [ -z "${START_REF}" ]; then
echo "error: detached HEAD; switch to a branch first." >&2
exit 1
fi
STATE_ROOT=$(git rev-parse --absolute-git-dir)
for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do
[ -e "${STATE_ROOT}/${state}" ] && {
echo "error: a '${state}' operation is in progress; finish or abort it first." >&2
exit 1
}
done
# `--porcelain` is deliberately stricter than `git diff --quiet`: it also reports
# untracked files, which would otherwise ride silently onto the new branch.
DIRTY=$(git status --porcelain)
if [ -n "${DIRTY}" ]; then
echo "error: working tree is not clean:" >&2
echo "${DIRTY}" | sed 's/^/ /' >&2
exit 1
fi
git show-ref --verify --quiet "refs/heads/${BASE}" || {
echo "error: no local '${BASE}' to branch from." >&2
exit 1
}
# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and
# `origin_https`, and `main` tracks the latter — `git fetch origin main` would
# check currency against a ref that is never updated here.
UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true)
if [ -n "${UPSTREAM}" ]; then
git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || {
echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2
echo " refusing to branch on a possibly stale '${BASE}'." >&2
exit 1
}
BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}")
AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}")
if [ "${BEHIND}" -ne 0 ] || [ "${AHEAD}" -ne 0 ]; then
echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2
[ "${AHEAD}" -ne 0 ] && echo " not yet pushed commits on '${BASE}': push them, or rebase this work onto them." >&2
[ "${BEHIND}" -ne 0 ] && echo " update it: git switch ${BASE} && git pull --ff-only" >&2
exit 1
fi
else
echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2
fi
restore() {
git switch --quiet "${START_REF}" 2>/dev/null || true
}
trap 'restore' EXIT HUP INT TERM
if [ "${START_REF}" != "${BASE}" ]; then
git switch --quiet "${BASE}"
fi
echo "Baseline: npm run test"
if ! npm run --silent test; then
echo "error: baseline is red on '${BASE}'; fix that first so later failures stay attributable." >&2
exit 1
fi
git switch --quiet --no-track -c "${NAME}"
trap - EXIT HUP INT TERM
# push.default=upstream is set here, so an inherited upstream would make a bare
# `git push` target main. Branching local-from-local does not set one anyway;
# --no-track says so out loud.
echo "Created ${NAME} from ${BASE} $(git rev-parse --short "${BASE}")."
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
# Release front-door. Run as `npm run release`. # Release front-door. Run as `npm run create:release`.
# #
# How we got here (short): we want hand-written Keep-a-Changelog notes, an # How we got here (short): we want hand-written Keep-a-Changelog notes, an
# [Unreleased] -> "## [x.y.z] - DATE" graduation, and a tag that marks the exact # [Unreleased] -> "## [x.y.z] - DATE" graduation, and a tag that marks the exact