diff --git a/AGENTS.md b/AGENTS.md index e415413..87ea08c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,9 +9,9 @@ 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). - **While iterating:** `npm run test` (`check:tsc` + the unit suite) for fast feedback on the files you changed. -- **Definition of done — run this before you call the work finished:** `npm run verify`. It is one shot of the whole-project correctness ladder (`npm run check`: `tsc → oxlint → oxfmt → cspell`, then the unit suite; tsc runs once), excluding advisory maintenance. 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 (`tsc` + `oxlint` + `oxfmt` + `cspell`) — 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.** `maintain:knip` (dead-code/deps) and `maintain:outdated` (registry) are advisory maintenance scans. Run them only on an explicit maintenance / update-deps branch; CI surfaces them via a non-blocking job, never as a gate. +- **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. +- **`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. ```sh npm run test # while iterating (fast feedback) @@ -37,6 +37,6 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt - [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 § Script prefix convention](./CONTRIBUTING.md#script-prefix-convention) — adding an `npm run` script? reuse an existing prefix or it doesn't belong. - [CONTRIBUTING.md § Commit messages](./CONTRIBUTING.md#commit-messages) — gitmoji + imperative + 50/72. -- [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when (`watch` / pre-commit / pre-push / `check` / `fix` / CI). +- [CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers) — what runs when and at what cost (`watch` / pre-commit / pre-push / `check` / `verify` / `fix` / `maintain` / CI). - [README.md § Tooling decisions](./README.md#tooling-decisions) — the rationale behind each tool choice; read before changing tooling. - [package.json `#scripts`](./package.json) — the source of truth for every command (the `LEFTHOOK_FILES` convention scopes them to staged files vs. the whole project). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2a14cb..1a7d5b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,8 @@ CI and review will bounce these even though `npm run check` and the linters don' - **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)) - **`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.** `maintain:knip` (~4s, whole project) and `maintain:outdated` (~6.5s, registry) are advisory, not correctness — they live under the `maintain:` prefix and run in CI as a **non-blocking** job, never gating a merge. (see [Why these splits](#why-these-splits) and [Script prefix convention](#script-prefix-convention)) -- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** They validate the publishable artifact (`dist/`) and run only in the CI `publish` job. (see [Publishing workflow](#publishing-workflow)) +- **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)) +- **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) ## Commit messages @@ -22,16 +22,16 @@ 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 `:` script is implicitly aggregated by a `` 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 by `npm 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 a `check:*` script. Aggregated by `npm run fix`. Use after `npm run check` to auto-resolve issues; the diff is the review surface. -- `test:*` — test scripts. `npm run test` is the canonical entry point (`check:tsc` + unit tests); `test:unit` skips the typecheck for fast local iteration; `test:ci` adds c8 coverage and is the CI variant. -- `watch:*` — long-running watchers, started manually via `npm run watch` for the inner dev loop. Sits "before" pre-commit in the feedback ladder (see Feedback tiers below). Currently a single child (`watch:test`); future `watch:oxlint` / `watch:tsc` would aggregate under the same `watch` umbrella. -- `maintain:*` — advisory repo-maintenance scans (dead code, dependency freshness): read-only, but whole-project and/or network-bound, so they are **not** correctness gates. Aggregated by `npm run maintain`. Run on an explicit maintenance / update-deps branch, and in CI as a non-blocking job (continue-on-error) that surfaces findings without ever failing a feature PR. -- `publish:*` — runs only at publish time, in the CI `publish` job (immediately before `npm publish`). There is **no** local `npm run publish` script — publishing is CI-only by policy. The `publish:` prefix still documents intent: this script validates the _publishable artifact_ (e.g., `dist/`) rather than the source. +- `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. (see [Rules the tools don't enforce](#rules-the-tools-dont-enforce) and [Publishing workflow](#publishing-workflow)) 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. -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 that composes `check` + `test:unit` into one whole-project correctness gate. 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`), 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. ## Feedback tiers @@ -52,16 +52,13 @@ The tools are organized into a feedback ladder. Each tier catches different thin ### 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 umbrella `watch` script is designed to aggregate multiple `watch:*` children (currently just `watch:test`); if more watchers are added later (e.g. `watch:oxlint`), the umbrella would switch to running them concurrently rather than sequentially. +- **`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. - **`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. -- **`maintain:knip` and `maintain:outdated` are advisory, not correctness** — knip scans the whole project (~4s) and `check-outdated` queries the npm registry (~6.5s, network-dependent, and it exits non-zero whenever any dep is behind). That's why they live under `maintain:`, are excluded from pre-commit and from `check`, and run in CI as a **non-blocking** job: a stale dependency must never block an unrelated feature PR. -- **`publish:publint` and `publish:attw` are NOT in `check`** — they belong to the `publish:` 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 CI `publish` job immediately before `npm publish`. -- **`verify` is a convenience umbrella, not a new tier.** `npm run verify` = `npm run check` + `npm run test:unit`, so a human or agent gets the whole-project correctness answer in one command. It uses `test:unit` (not `test`) because `check` already runs `tsc`, so the type checker runs exactly once. It excludes `maintain` by design, and CI still runs `check` + `test:ci` separately (to also collect coverage), so `verify` is a local/dev affordance. ### Before pushing -Run `npm run verify` locally — it is the one-shot whole-project correctness gate (`check` + unit tests). Anything `maintain:knip` or `maintain:outdated` would catch is reported by the non-blocking CI `maintain` job; run `npm run maintain` yourself only on a maintenance / update-deps branch. +Run `npm run verify` — the one-shot correctness gate in the table above. Run `npm run maintain` only on a maintenance / update-deps branch. ## Publishing workflow diff --git a/README.md b/README.md index 3898b42..afea147 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,32 @@ Pattern matching for TypeScript/ESM environments (F#-style, not regex). - **Build:** `npm run build` - **Test:** `npm run test`, `npm run test:ci` -- **Watch:** `npm run watch` (re-runs tests on file save; the earliest feedback tier) +- **Watch:** `npm run watch` - **Checks:** `npm run check`, `npm run fix` -- **Verify (definition of done):** `npm run verify` — `npm run check` + the unit suite in one shot; the whole-project correctness gate (excludes advisory `maintain`) -- **Maintenance (advisory):** `npm run maintain` — `knip` + `check-outdated`; run on a maintenance / update-deps branch, not part of the feature loop +- **Verify:** `npm run verify` — the definition of done +- **Maintenance:** `npm run maintain` — advisory only - **Individual fixes:** `npm run fix:oxfmt`, `npm run fix:oxlint` +What each tier runs, when it fires and what it costs: +[CONTRIBUTING.md § Feedback tiers](./CONTRIBUTING.md#feedback-tiers). How the +`prefix:` in a script name is chosen: +[§ Script prefix convention](./CONTRIBUTING.md#script-prefix-convention). + ### Tooling - **TypeScript 7** — type checker and build (`tsc`). - **node --test** + `--experimental-strip-types` — test runner (Node 22.6+, flag dropped on Node 24). - **c8** — code coverage for `test:ci`. -- **oxlint** — Rust-based linter. Type-aware rules are enabled via `options.typeAware: true` in `.oxlintrc.json` (powered by `oxlint-tsgolint`). -- **oxlint-tsgolint** — type-aware linting via typescript-go (declarative via `.oxlintrc.json`, no CLI flag). Catches unsafe type assertions, unnecessary type parameters, and other type-system issues that regular oxlint can't see. Source-level `oxlint-disable` directives are used to silence known false positives (e.g., `expectTypeOf()` in test files). +- **oxlint** — Rust-based linter, with type-aware rules powered by **oxlint-tsgolint** (typescript-go). - **oxfmt** — Rust-based formatter (Prettier-compatible). Formats JS/TS, JSON/JSONC, YAML, Markdown, MDX, and more; built-in `package.json` key sorting replaces `sort-package-json`. - **cspell** — spell checking. -- **knip** — finds unused dependencies, exports, and files. Scoped via `--include dependencies,exports,files` to skip the noisy `types` category (which produces false positives for libraries whose exported types are part of the public API). -- **publint** — validates `package.json` for ESM publishing correctness. Runs on publish only (in CI), not as part of `npm run check`. -- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. Runs on publish only with `--profile esm-only` (the package is intentionally ESM-only). -- **lefthook** — git pre-commit hooks. +- **knip** — finds unused dependencies, exports, and files. +- **check-outdated** — reports dependencies behind the registry; it exits non-zero whenever _any_ dependency is outdated. +- **publint** — validates `package.json` for ESM publishing correctness. +- **@arethetypeswrong/cli** (`attw`) — validates `.d.ts` declarations against multiple module-resolution scenarios. +- **lefthook** — git hooks. + +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). ### Tooling decisions @@ -36,10 +43,7 @@ The choice and configuration of each tool above is the result of deliberate trad - **Source-level `oxlint-disable` directives** are used for known type-aware false positives (see `src/pattern.ts`, `src/match.ts`, `src/index.test.ts`). The disable lives next to the code it silences, not in `.oxlintrc.json`, so the trade-off is visible to anyone reading the source. - **`knip --include dependencies,exports,files`** intentionally omits the `types` category, which produces systematic false positives for libraries whose exported types are part of the public API. The targeted scope keeps the signal high without config-file boilerplate. - **`attw --profile esm-only`** is semantically correct: this package is intentionally ESM-only (no CommonJS shim), so CJS resolution scenarios are out of scope by design, not a bug. -- **The `publish:` prefix has no local aggregator.** `publint` and `attw` validate the _publishable artifact_ (`dist/`), not the source, and require a fresh build. They run only in the CI `publish` job immediately before `npm publish` — there is intentionally no `npm run publish`. - **`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). -- **`verify` is the one-shot definition of done.** `npm run verify` composes `npm run check` with the unit suite (`test:unit`) into a single whole-project correctness gate, so a human or an agent reaches for one command instead of re-deriving the sequence. It deliberately uses `test:unit` (not `test`) because `check` already runs `check:tsc` — so tsc runs exactly once. It excludes `maintain` (advisory) by design. CI is not switched to it: the build job runs `check` + `test:ci` to also collect coverage. -- **The `check:` / `maintain:` split is correctness gates vs. advisory scans.** `npm run check` is the fast, offline, whole-project correctness ladder (`tsc → oxlint → oxfmt → cspell`) and can run anywhere, including the agent loop. `knip` (~4s, whole project) and `check-outdated` (~6.5s, queries the npm registry) are advisory, not correctness — a stale dependency or an unused export must not fail a feature PR — so they moved to `npm run maintain`, kept out of pre-commit, and run in CI as a **non-blocking** job (see `.github/workflows/ci.yml`). Note `check-outdated` exits non-zero whenever any dep is outdated, which is exactly why it must not gate merges. - **The pre-commit hook sets the `LEFTHOOK_FILES` env var** to the staged-files list, and the affected scripts use `${LEFTHOOK_FILES:-}` 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. @@ -53,28 +57,9 @@ The choice and configuration of each tool above is the result of deliberate trad - TypeScript 7 is used via the `typescriptteam.native-preview` extension. - oxc extension provides oxlint squiggles and oxfmt format-on-save. -## Workflows - -- Version updates via `npm version`. -- Publishing via GitHub Actions on tagged commits (see `.github/workflows/ci.yml`); the `publish` job runs `publish:publint` and `publish:attw` before `npm publish`. - -## Script prefix convention - -Script names follow a prefix convention that signals _when_ they run: - -- `check:*` — read-only verification. Aggregated by `npm run check`. Used in pre-commit hooks and CI's build job. -- `fix:*` — mutating counterpart of `check:*`. Aggregated by `npm run fix`. Use after `npm run check` to auto-resolve issues. -- `test:*` — test scripts. `npm run test` runs the full suite; `test:unit` / `test:ci` are scope-specific variants. -- `maintain:*` — advisory repo-maintenance scans (dead code, dependency freshness). Aggregated by `npm run maintain`. Whole-project and/or network-bound, so **not** correctness gates: run on a maintenance branch, and in CI as a non-blocking job that reports without failing. -- `publish:*` — runs only at publish time, in the CI `publish` job (immediately before `npm publish`). There is no local `npm run publish` script — publishing is CI-only by policy. - -Bare, prefix-free top-level commands are the entry points: `build`, `clean`, `check`, `fix`, `test`, `watch`, `maintain`, and `verify`. `verify` (`check` + `test:unit`) is the one-shot "whole-project correctness" gate; `maintain` is the advisory counterpart that never gates a merge. - ## Contributing For maintainer and contributor docs — the script prefix convention, the feedback-tier system, the rules the tools don't enforce, and the publishing workflow — see [CONTRIBUTING.md](./CONTRIBUTING.md). AI coding agents: your entry point is [AGENTS.md](./AGENTS.md), which points back to CONTRIBUTING.md. - Commit signing (GPG). -- Set up commit message template: `npm run use:git-commit-message`. -- See `commit-message-template`. - Type-only tests use `expect-type`'s `expectTypeOf(...)` inside `node --test` cases.