@avi2dg/checks 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/CONTRIBUTING.md +89 -0
  3. package/README.md +137 -1230
  4. package/dist/feature-rules.js +14 -1
  5. package/docs/configs/commit-messages.md +43 -0
  6. package/docs/configs/dependency-rules.md +62 -0
  7. package/docs/configs/effect-rules.md +48 -0
  8. package/docs/configs/quality-file.md +99 -0
  9. package/docs/design.md +77 -0
  10. package/docs/gates/checks-backtest.md +50 -0
  11. package/docs/gates/checks-ci-wiring.md +122 -0
  12. package/docs/gates/checks-comment-gate.md +58 -0
  13. package/docs/gates/checks-commit-identity.md +63 -0
  14. package/docs/gates/checks-docs.md +98 -0
  15. package/docs/gates/checks-feature-owners.md +113 -0
  16. package/docs/gates/checks-flake.md +88 -0
  17. package/docs/gates/checks-lint-coverage.md +49 -0
  18. package/docs/gates/checks-lint.md +136 -0
  19. package/docs/gates/checks-mutation-compare.md +84 -0
  20. package/docs/gates/checks-quality.md +94 -0
  21. package/docs/gates/checks-size-budget.md +64 -0
  22. package/docs/gates/checks-suppressions-ratchet.md +51 -0
  23. package/docs/gates/checks-test-layout.md +77 -0
  24. package/docs/gates/checks-test.md +75 -0
  25. package/package.json +24 -3
  26. package/quality.schema.json +48 -0
  27. package/scripts/doc-outline.ts +215 -0
  28. package/scripts/doc-rules.ts +177 -0
  29. package/scripts/doc-templates.ts +208 -0
  30. package/scripts/docs.ts +90 -0
  31. package/scripts/gates.ts +1 -0
  32. package/scripts/quality-file.ts +22 -1
  33. package/templates/adr.md +29 -0
  34. package/templates/agents.md +17 -0
  35. package/templates/changelog.md +37 -0
  36. package/templates/claude.md +2 -0
  37. package/templates/explanation.md +15 -0
  38. package/templates/how-to.md +33 -0
  39. package/templates/readme.md +45 -0
  40. package/templates/reference.md +15 -0
  41. package/templates/tutorial.md +31 -0
package/README.md CHANGED
@@ -1,1275 +1,182 @@
1
1
  # checks
2
2
 
3
- Deterministic checks shared across my TypeScript repos. One package,
4
- `@avi2dg/checks`: the `checks-lint` entry point that runs every lint gate
5
- below over a range it resolves itself, the `checks-test` entry point
6
- that runs the suite and refuses an undeclared skip, the `checks-flake`
7
- run that records the seeds a failing test fails with, the oxlint base
8
- config, the tsconfig fragment with the Effect language-service block,
9
- the `quality.json` schema with the generator that turns its Effect paths
10
- into oxlint and tsconfig fragments, the shared commitlint config, the
11
- shared dependency-cruiser base with the feature-owner rules `quality.json`
12
- compiles into it, the test-layout check with its bunfig preset, the commit-identity check, the
13
- comment gate with its backtest, the oxlint suppressions ratchet, the
14
- size budget, the feature-owner change signal and proof check, the
15
- Stryker mutation-testing preset with its no-regression comparator, the
16
- CI-wiring check, and the Effect error-channel plugin compiled to
17
- JavaScript.
3
+ `@avi2dg/checks` is the kit of deterministic checks a TypeScript repository installs to hold its code, tests, commits, CI wiring and docs to one shared standard.
4
+ It ships the lint gates `checks-lint` runs over each pull request, the test runners, and the configs a repository extends for oxlint, tsc, dependency-cruiser, commitlint, bun and Stryker.
5
+ A repository declares what it opts into once, in `quality.json`, and each check decides its constraint the same way on every run.
18
6
 
19
- Published as `@avi2dg/checks` on the public npm registry.
7
+ ## Before you begin
20
8
 
21
- ## Consume it
9
+ <!-- generated prerequisites: bun run build writes it from package.json, .bun-version and scripts/doc-blocks.ts -->
22
10
 
23
- From the consuming repo:
11
+ - A git repository, whose history the range gates read.
12
+ - Bun 1.3.13, which runs every bin.
13
+ - TypeScript 7.0.2, whose `tsc` the `typecheck` script runs.
14
+ - The peer dependencies, at the exact versions the kit pins:
15
+ - `@effect/tsgo` 0.45.0
16
+ - `@swc/core` 1.16.2
17
+ - `dependency-cruiser` 18.4.0
18
+ - `effect` 4.0.0-rc.115
19
+ - `oxlint` 1.83.0
20
+ - `oxlint-tsgolint` 7.0.2002
24
21
 
25
- ```sh
26
- bun add -d @avi2dg/checks effect@4.0.0-rc.115 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 @effect/tsgo@0.45.0 typescript@7.0.2 dependency-cruiser@18.4.0 @swc/core@1.16.2
27
- ```
22
+ <!-- end generated prerequisites -->
28
23
 
29
- `.oxlintrc.json`:
24
+ ## Install
30
25
 
31
- ```json
32
- {
33
- "extends": ["./node_modules/@avi2dg/checks/oxlintrc.json"],
34
- "plugins": ["typescript", "oxc", "eslint", "import"]
35
- }
36
- ```
26
+ To consume the kit from a repository:
37
27
 
38
- `.gitignore` keeps oxlint out of `node_modules/`:
28
+ 1. Add the kit and its peers:
39
29
 
40
- ```
41
- node_modules/
42
- ```
30
+ <!-- generated install: bun run build writes it from package.json and scripts/doc-blocks.ts -->
43
31
 
44
- `tsconfig.json` gains one line:
32
+ ```sh
33
+ bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0-rc.115 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
34
+ ```
45
35
 
46
- ```json
47
- {
48
- "extends": "@avi2dg/checks/tsconfig.effect.json"
49
- }
50
- ```
36
+ <!-- end generated install -->
51
37
 
52
- `quality.json` at the repository root declares what the repository
53
- opts into, starting with the commands its CI runs; see "Quality file"
54
- below:
38
+ 1. Extend the oxlint base in `.oxlintrc.json`, restating `plugins`:
55
39
 
56
- ```json
57
- {
58
- "$schema": "./node_modules/@avi2dg/checks/quality.schema.json",
59
- "gates": { "ci": ["bun run lint", "bun run typecheck", "bun run test"] }
60
- }
61
- ```
40
+ ```json
41
+ {
42
+ "extends": ["./node_modules/@avi2dg/checks/oxlintrc.json"],
43
+ "plugins": ["typescript", "oxc", "eslint", "import"]
44
+ }
45
+ ```
62
46
 
63
- `bunfig.toml` is a copy of the shipped preset:
47
+ 1. Keep oxlint out of `node_modules/` through `.gitignore`:
64
48
 
65
- ```sh
66
- cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
67
- ```
49
+ ```
50
+ node_modules/
51
+ ```
68
52
 
69
- `package.json` gains three scripts:
53
+ 1. Extend the tsconfig fragment in `tsconfig.json`:
70
54
 
71
- ```json
72
- "lint": "oxlint --type-aware && checks-lint",
73
- "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
74
- "test": "checks-test"
75
- ```
55
+ ```json
56
+ {
57
+ "extends": "@avi2dg/checks/tsconfig.effect.json"
58
+ }
59
+ ```
76
60
 
77
- `checks-test` runs `bun test --randomize` and fails on a skip the
78
- repository has not declared; see "Test entry point" below.
61
+ 1. Declare the commands CI runs in `quality.json` at the repository root:
79
62
 
80
- `checks-lint` runs every kit gate a lint needs; see "Lint entry point"
81
- below. Three of them:
63
+ ```json
64
+ {
65
+ "$schema": "./node_modules/@avi2dg/checks/quality.schema.json",
66
+ "gates": { "ci": ["bun run lint", "bun run typecheck", "bun run test"] }
67
+ }
68
+ ```
82
69
 
83
- `lint-coverage.sh` fails when oxlint silently skips a tracked `.ts` or
84
- `.tsx` file, for example through a stray `.gitignore` entry. It compares
85
- `git ls-files` against oxlint's own file walk and names the missing files.
86
- It exits 2 when oxlint cannot walk the tree, as when it is not on `PATH`
87
- or its config does not parse.
70
+ 1. Copy the bunfig preset:
88
71
 
89
- `test-layout.ts` decides the test layout described below.
72
+ ```sh
73
+ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
74
+ ```
90
75
 
91
- `commit-identity.ts` refuses a commit with an author other than the
92
- repository owner; see "Commit identity" below.
76
+ 1. Add three scripts to `package.json`:
93
77
 
94
- A repo that runs mutation testing installs `@stryker-mutator/core` and
95
- `@hughescr/stryker-bun-runner`, then spreads the shipped preset in
96
- `stryker.conf.mjs`:
78
+ ```json
79
+ "lint": "oxlint --type-aware && checks-lint",
80
+ "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
81
+ "test": "checks-test"
82
+ ```
97
83
 
98
- ```js
99
- import preset from "@avi2dg/checks/stryker.preset.js";
84
+ 1. Run the three on every pull request in `.github/workflows/ci.yml`, fetching the whole history the range needs:
100
85
 
101
- export default {
102
- ...preset,
103
- };
104
- ```
86
+ ```yaml
87
+ on:
88
+ pull_request:
89
+ jobs:
90
+ checks:
91
+ runs-on: ubuntu-latest
92
+ steps:
93
+ - uses: actions/checkout@v5
94
+ with:
95
+ fetch-depth: 0
96
+ - uses: oven-sh/setup-bun@v2
97
+ - run: bun install --frozen-lockfile
98
+ - run: bun run lint
99
+ - run: bun run typecheck
100
+ - run: bun run test
101
+ ```
105
102
 
106
- The registry version is pinned by the consumer's lockfile; bump
107
- `@avi2dg/checks` to adopt a new release.
103
+ `bun run lint` then ends with `checks-lint: <count> gate(s) pass`.
108
104
 
109
- ## Quality file
105
+ ## What runs
110
106
 
111
- `quality.json` at the repository root says what the repository has
112
- opted into. The kit's bins find it at the git root and read it there:
107
+ `checks-lint` runs these gates in this order, each over the working tree or over the range it resolves, and names every one that fails.
108
+ A repository leaves out a gate that does not apply to it through `gates.lint`, as [Gate selection](docs/gates/checks-lint.md#gate-selection) says.
113
109
 
114
- ```json
115
- {
116
- "$schema": "./node_modules/@avi2dg/checks/quality.schema.json",
117
- "defaultBranch": "main",
118
- "gates": {
119
- "ci": ["bun run lint", "bun run typecheck", "bun run test"],
120
- "scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
121
- },
122
- "commitIdentity": { "authors": [{ "name": "avi2d", "email": "avi2dg@gmail.com" }] },
123
- "sources": {
124
- "production": ["src/**/*.ts"],
125
- "effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
126
- },
127
- "size": { "fileLines": 400, "functionLines": 100, "applies": "changed" },
128
- "features": [
129
- {
130
- "name": "billing",
131
- "root": "src/billing",
132
- "entries": ["src/billing/index.ts"],
133
- "allowFrom": ["src/main.ts"],
134
- "proof": "tests/e2e/billing.test.ts"
135
- }
136
- ],
137
- "changeSignal": "advisory",
138
- "agentRules": { "on": [], "off": [] }
139
- }
140
- ```
110
+ <!-- generated gates: bun run build writes it from KIT_GATES in scripts/gates.ts and scripts/doc-blocks.ts -->
141
111
 
142
- | Key | Read by | Holds |
112
+ | Gate | Reads | Runs in |
143
113
  | --- | --- | --- |
144
- | `defaultBranch` | `checks-lint`, `checks-ci-wiring` | the branch pull requests merge into, `main` when absent |
145
- | `gates.ci` | `checks-ci-wiring` | the commands CI runs on every pull request; see "CI wiring" |
146
- | `gates.scheduled` | `checks-ci-wiring` | the commands a schedule runs |
147
- | `gates.lint` | `checks-lint`, `checks-ci-wiring` | the gates `checks-lint` runs when not all apply; see "Gate selection" |
148
- | `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit; see "Commit identity" |
149
- | `sources.effect` | `checks-quality` | the paths held to the Effect rules, and the files under them that are not; see "Effect rules" |
150
- | `sources.production` | `checks-size-budget`, `checks-quality` | the source the repository ships; see "Size budget" |
151
- | `size` | `checks-size-budget` | the line budget, and which production files it holds; see "Size budget" |
152
- | `features` | `featureRules`, `checks-feature-owners` | each feature's root, entries, exempt importers and proof; see "Feature owners" |
153
- | `changeSignal` | `checks-feature-owners` | `advisory` to list the feature owners a change touches; see "Feature owners" |
154
- | `agentRules.on`, `agentRules.off` | agent Rule selection, not the kit | catalogued Rules switched on or off for this repository |
114
+ | [`checks-lint-coverage`](docs/gates/checks-lint-coverage.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
115
+ | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
116
+ | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
117
+ | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
118
+ | [`checks-suppressions-ratchet`](docs/gates/checks-suppressions-ratchet.md) | the range | every repository |
119
+ | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
120
+ | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
121
+ | [`checks-quality`](docs/gates/checks-quality.md) | the working tree | a repository tracking `quality.json` |
122
+ | [`checks-size-budget`](docs/gates/checks-size-budget.md) | the range | a repository tracking `*.ts` or `*.tsx` |
123
+ | [`checks-feature-owners`](docs/gates/checks-feature-owners.md) | the range | a repository tracking `*.ts` or `*.tsx` |
155
124
 
156
- Every key is optional. The bins decode the file with one Effect
157
- `Schema`, and the package ships `quality.schema.json` emitted from that
158
- schema, so the `$schema` line gives an editor the verdict the bins
159
- reach, save what no JSON Schema can express across two values, which
160
- the bins refuse: a Rule switched both on and off, a feature entry
161
- outside its root, and two features with one name or sharing a root. A key the schema does
162
- not name is refused, not ignored, so a misspelt `sources` cannot switch
163
- the Effect rules off unnoticed. A glob in `sources`
164
- starts at the repository root, names a directory first, uses `*`
165
- only within a segment and `**` only as a whole one, and ends in a file
166
- name with an extension, which are the globs oxlint, the language
167
- service and git all read alike. oxlint matches `*.ts` at any depth
168
- where the other two match it at the root alone, so the schema refuses
169
- it; `**/*.ts` means every depth to all three. The language service
170
- matches nothing for `src/**` and oxlint nothing for `src/lib`, so the
171
- schema refuses both; `src/**/*.ts` and `src/lib/*.ts` say it to all
172
- three.
125
+ <!-- end generated gates -->
173
126
 
174
- Until a later minor release, a repository with no `quality.json` still
175
- has `ciWiring` and `commitIdentity` read from `package.json`, with a
176
- notice on each read. One with both exits 2 until `package.json` drops
177
- them. The keys map one for one:
127
+ These bins run on their own:
178
128
 
179
- | `package.json` | `quality.json` |
180
- | --- | --- |
181
- | `ciWiring.gates` | `gates.ci` |
182
- | `ciWiring.scheduled` | `gates.scheduled` |
183
- | `ciWiring.lintGates` | `gates.lint` |
184
- | `ciWiring.defaultBranch` | `defaultBranch` |
185
- | `commitIdentity` | `commitIdentity` |
186
-
187
- ### Generated fragments
188
-
189
- oxlint and tsc read their own JSON and nothing else, so
190
- `checks-quality generate` writes what `sources.effect` declares into two
191
- fragments at the repository root, and the hand-written configs extend
192
- them. Both fragments are committed:
193
-
194
- ```sh
195
- checks-quality generate
196
- checks-quality --check
197
- ```
198
-
199
- `.oxlintrc.json`:
200
-
201
- ```json
202
- {
203
- "extends": ["./node_modules/@avi2dg/checks/oxlintrc.json", "./oxlintrc.quality.json"],
204
- "plugins": ["typescript", "oxc", "eslint", "import"]
205
- }
206
- ```
129
+ - [`checks-test`](docs/gates/checks-test.md) runs the suite as `scripts.test` and refuses a skip the repository has not declared.
130
+ - [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
131
+ - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) holds a pull request's mutation score to no regression.
132
+ - [`checks-backtest`](docs/gates/checks-backtest.md) reports what the comment check would have refused in recent history.
207
133
 
208
- `tsconfig.json`:
134
+ `checks-lint` has [its own page](docs/gates/checks-lint.md), which says which range it resolves.
135
+ The oxlint base, the dependency-cruiser base and the commitlint config run through their own tools, as the pages under Related topics say.
209
136
 
210
- ```json
211
- {
212
- "extends": ["@avi2dg/checks/tsconfig.effect.json", "./tsconfig.quality.json"]
213
- }
214
- ```
137
+ ## Upgrade
215
138
 
216
- `oxlintrc.quality.json` holds one override: the declared paths as
217
- `files`, the exempt ones as `excludeFiles`, and the kit's Effect rule
218
- block, `presets/effect.oxlint.json`. `tsconfig.quality.json` holds the
219
- language-service override: the same paths as `include`, the exempt ones
220
- as `exclude`, and `presets/effect.language-service.json`. A kit release
221
- that changes a preset reaches the repository through its next
222
- `generate`. A rule only this repository needs stays in its own
223
- `.oxlintrc.json`, whose overrides come after the fragment's and so win.
139
+ To move a repository to a newer release of the kit:
224
140
 
225
- `checks-lint` runs `checks-quality --check`, which exits 1 when:
141
+ 1. Run the install line again, which moves the kit to its newest release and the peers to the versions it pins.
142
+ 1. Run `bun run checks-quality generate` when `quality.json` declares `sources.effect`, since a release that changes a preset reaches the fragments only through it.
143
+ 1. Copy `node_modules/@avi2dg/checks/bunfig.toml` over `bunfig.toml` again, since `checks-test-layout` compares the copy with the installed preset.
144
+ 1. Run `bun run lint`, `bun run typecheck` and `bun run test`.
226
145
 
227
- - a fragment is missing, or differs from what `generate` would write
228
- from `quality.json` and the installed kit's presets;
229
- - a fragment is left over once `quality.json` stops declaring
230
- `sources.effect`;
231
- - `.oxlintrc.json` or `tsconfig.json` does not list its fragment in
232
- `extends`, so the tool never reads it;
233
- - a `sources.effect.paths` glob, or a `sources.production` glob while
234
- `size` is declared, matches no tracked or untracked file, so it holds
235
- nothing.
146
+ The repository's lockfile pins the kit, so a repository moves only when it runs these steps.
147
+ [CHANGELOG.md](CHANGELOG.md), shipped in the package, lists what each release changed.
148
+ A repository that still declares `ciWiring` or `commitIdentity` in `package.json` moves them into `quality.json`, as [Keys moved from package.json](docs/configs/quality-file.md#keys-moved-from-packagejson) maps.
236
149
 
237
- It exits 2 when `quality.json` does not decode. `generate` writes the
238
- fragments, removes a left-over one, then runs the same check.
150
+ ## Where things are
239
151
 
240
- ```
241
- checks-quality: 2 problem(s) with what quality.json declares:
242
- oxlintrc.quality.json is stale against quality.json and the kit's presets; run checks-quality generate
243
- tsconfig.json does not extend ./tsconfig.quality.json, so the language service never reads it
244
- ```
152
+ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
245
153
 
246
- Two details of the fragments are easy to get wrong, so the kit's tests
247
- pin both:
154
+ <!-- generated shipped: bun run build writes it from package.json and scripts/doc-blocks.ts -->
248
155
 
249
- - A fragment sits at the repository root. oxlint resolves an
250
- override's `files`, and the language service an override's `include`,
251
- against the directory of the config holding it. From `.quality/` the
252
- language service reports no error at all on an `async function`
253
- planted under a declared path.
254
- - The oxlint fragment always sets `plugins`, to the kit's. A config in
255
- `extends` that sets none brings in oxlint's default plugins, whose
256
- category rules then fire across the whole tree. The override names
257
- the kit's plugins beside the preset's `node`, `promise` and `unicorn`,
258
- because one that leaves any of the kit's out turns on the category
259
- rules of the plugins it adds under every declared path.
260
-
261
- ## Lint entry point
262
-
263
- `checks-lint` runs each of the kit's lint gates in turn and names every
264
- one that fails, rather than stopping at the first. A repository whose
265
- tracked files give a gate nothing to check can leave it out through
266
- `gates.lint`; see "Gate selection" under "CI wiring".
267
-
268
- | Gate | Reads |
156
+ | Path | What it holds |
269
157
  | --- | --- |
270
- | `checks-lint-coverage` | the working tree |
271
- | `checks-test-layout` | the working tree |
272
- | `checks-commit-identity` | the range |
273
- | `checks-comment-gate` | the range |
274
- | `checks-suppressions-ratchet` | the range |
275
- | `checks-ci-wiring` | the working tree |
276
- | `checks-quality` | the working tree |
277
- | `checks-size-budget` | the range |
278
- | `checks-feature-owners` | the range |
279
-
280
- ```sh
281
- checks-lint
282
- checks-lint <base-ref> <head-ref>
283
- ```
284
-
285
- It resolves the range once and hands the same one to every range gate.
286
- Locally, and on any event other than a pull request, the range ends at
287
- `HEAD` and starts where `HEAD` branched from the origin default branch:
288
- `origin/HEAD`, or when `origin/HEAD` is not set, as in an
289
- `actions/checkout` clone, `origin/<defaultBranch>` from the
290
- repository's `quality.json` (see "Quality file"), and `origin/main` when
291
- that is not declared. In a GitHub Actions pull request, where
292
- `GITHUB_EVENT_NAME` is `pull_request`, it ends
293
- at the event's head sha and starts where that branched from
294
- `origin/<base branch>`, so GitHub's merge commit is never in it. The
295
- base branch is read from the fetch, not from the event's recorded base
296
- sha, which GitHub leaves stale once the base branch advances after the
297
- pull request opens. Explicit base and head arguments override both.
298
-
299
- The range always starts at the merge base, never at the base branch's
300
- tip: commits the base branch gained after the head branched off would
301
- otherwise count against the head. When the head is the merge base, as
302
- on a push to the default branch or a local run on it, the range would be
303
- empty, so each range gate is handed that tip commit alone and checks it
304
- against its parent, or against the empty tree when it is a repository's
305
- first commit:
306
-
307
- ```
308
- checks-lint: tip 10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
309
- ```
310
-
311
- A clone with no remote-tracking refs at all, such as a freshly
312
- initialised repository with no remote or one whose remote was never
313
- fetched, has no default branch to start from, so each range gate is
314
- handed `HEAD` alone the same way:
315
-
316
- ```
317
- checks-lint: tip 10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD alone, as the clone has no remote-tracking refs
318
- ```
319
-
320
- Once any ref sits under `refs/remotes/`, a missing
321
- `origin/<default branch>` exits 2 instead, since that is the shape of a
322
- shallow CI checkout, where `HEAD` alone would leave the commits before
323
- it unchecked.
324
-
325
- It prints the range, the declared selection if there is one, each
326
- gate's own report, then its verdict:
327
-
328
- ```
329
- checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
330
- ...
331
- checks-lint: 3 of 9 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
332
- ```
333
-
334
- It exits 1 when any gate found a violation, and 2 when the range or the
335
- selection does not resolve, or no failing gate could decide. ci-wiring
336
- always runs, so a repository on `checks-lint` declares `gates.ci`
337
- (see "CI wiring"), and it holds the test layout unless its selection
338
- leaves out `checks-test-layout`.
339
-
340
- CI runs it through `lint`. The checkout fetches the whole history, which
341
- the merge base needs:
342
-
343
- ```yaml
344
- on:
345
- pull_request:
346
- jobs:
347
- lint:
348
- runs-on: ubuntu-latest
349
- steps:
350
- - uses: actions/checkout@v5
351
- with:
352
- fetch-depth: 0
353
- - uses: oven-sh/setup-bun@v2
354
- - run: bun install --frozen-lockfile
355
- - run: bun run lint
356
- ```
357
-
358
- ## Effect rules
359
-
360
- The base config loads the `effect-channel` plugin and turns on
361
- `effect-channel/no-error-channel-escape`, which refuses `Effect.ignore`,
362
- `Effect.ignoreCause`, the `Effect.catchCause` family, and an
363
- `Effect.catch` whose handler takes no error or names it `_`.
364
-
365
- Two more rules ship off, because a repository writes only some of its
366
- paths in Effect: code a host loads without `node_modules`, such as a
367
- hook bundle or this oxlint plugin, cannot import it.
368
-
369
- - `effect-channel/no-throw` refuses a `throw` statement.
370
- - `effect-channel/no-try-catch` refuses a `try` statement with a
371
- `catch` clause. `try`/`finally` stays allowed.
372
-
373
- Each refusal says what to write instead: a `Schema.TaggedError` failed
374
- through `Effect.fail`, a throwing call wrapped in `Effect.try` or
375
- `Effect.tryPromise`, and recovery by tag with `Effect.catchTag`.
376
-
377
- A repository turns them on for the paths it writes in Effect by
378
- declaring those paths in `quality.json` and extending the generated
379
- fragments; see "Generated fragments" under "Quality file":
380
-
381
- ```json
382
- "sources": {
383
- "effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
384
- }
385
- ```
386
-
387
- The fragment's override carries the kit's Effect rule block,
388
- `presets/effect.oxlint.json`: the two rules above, plus `node/no-sync`,
389
- `oxc/no-async-await`, `promise/avoid-new` and `unicorn/no-process-exit`.
390
- Files under `exempt` answer to none of them.
391
-
392
- oxlint resolves `files` against the directory of the config that holds
393
- the override, so a config passed with `-c` from outside the repository
394
- matches nothing and reports nothing.
395
-
396
- `unicorn/no-process-exit` passes over any file that opens with a
397
- shebang, so a repository whose bins open with one bans `process.exit`
398
- itself with `no-restricted-properties` in its own `.oxlintrc.json`, as
399
- this one does. The preset leaves that rule out because
400
- a repository's own `no-restricted-properties` list for the same files
401
- would replace it, or be replaced by it. Sites standing when the
402
- declaration lands go in oxlint's own baseline, `oxlint --suppress-all`,
403
- so their count can only fall. This repository's own `.oxlintrc.json`
404
- also lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`,
405
- the one file under its Effect path that a host loads without
406
- `node_modules`.
407
-
408
- The language service holds the same paths to Effect-native IO through
409
- the tsconfig fragment's override, whose severities are
410
- `presets/effect.language-service.json`: `nodeBuiltinImport`,
411
- `asyncFunction`, `newPromise` and `extendsNativeError`, all errors.
412
- effect-tsgo keeps the severities `tsconfig.effect.json` sets when a later
413
- config in `extends` restates the plugin with only its overrides.
414
-
415
- ## Test layout
416
-
417
- `checks-test-layout` fails unless the repo holds this shape, and names the
418
- file and the path to move it to when it does not:
419
-
420
- - Every test file is `tests/**/*.test.ts` or `.tsx`. A `*.test.ts`, `*.spec.ts` or
421
- `*_test.ts` under `src/`, `test/`, `__tests__/` or the repo root fails.
422
- Tracked and untracked files that `git ls-files --exclude-standard`
423
- reports are scanned, so `node_modules/` and every gitignored tree are
424
- out of reach, and a local run agrees with CI before `git add`.
425
- - `tests/lib/**` holds helpers and `tests/fixtures/**` holds data; neither
426
- may hold a test file. Every other directory directly under `tests/` is a
427
- test group and may nest as deep as it likes.
428
- - Two levels. A test outside `tests/e2e/` runs in-process, so it may not
429
- import `node:child_process`, `net`, `http`, `https`, `http2`, `tls` or
430
- `dgram`, may not import `$`, `spawn`, `spawnSync`, `connect`, `serve` or
431
- `listen` from `bun`, may not touch `Bun.$` or `Bun.spawn`, and may not
432
- call `fetch`. A test inside `tests/e2e/` may do all of it. Helpers in
433
- `tests/lib/**` answer to the same rule, since an in-process test reaches
434
- them; `tests/fixtures/**` is data and is not parsed. Detection parses
435
- with swc and reads import specifiers and identifier use, so a test that
436
- only carries `"node:child_process"` as a string is not a violation.
437
- - `scripts.test` is exactly `checks-test`, which runs `bun test --randomize`
438
- (see "Test entry point"), and `scripts.lint` runs this check, itself or
439
- through `checks-lint` called by its bare bin name.
440
- - `bunfig.toml` carries every `[test]` key of the shipped preset with the
441
- same value, and `[test].pathIgnorePatterns` is always
442
- `["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
443
- whose bunfig is the preset, cannot drift it either. Other tables, and
444
- extra `[test]` keys, are the repo's own.
445
-
446
- The in-process half is what a mutation run can mutate; `tests/e2e/**` is
447
- excluded from a mutate scope by construction, because a subprocess kills
448
- both the speed and the coverage signal a mutant needs.
449
-
450
- The preset also skips `tests/quarantine/**` on a default run. A test that
451
- turns flaky moves there, so the suite stays trustworthy, and the flake is
452
- still run on demand:
453
-
454
- ```sh
455
- bun test --path-ignore-patterns='' tests/quarantine
456
- ```
457
-
458
- ## Test entry point
459
-
460
- `checks-test` runs the whole suite with `bun test --randomize`, passes
461
- bun's output through, and then reads bun's JUnit report of the same run.
462
- bun exits 0 with tests skipped, so a green run says nothing about the
463
- tests that never ran. `checks-test` fails when a test was skipped, by
464
- `test.skip`, `test.skipIf`, `test.if`, `describe.skip` or `test.todo`,
465
- without a declaration in `package.json`:
466
-
467
- ```json
468
- "testSkips": [
469
- {
470
- "file": "tests/e2e/docker.test.ts",
471
- "test": "images > builds the release image",
472
- "reason": "the runner has no docker daemon",
473
- "when": "ci"
474
- }
475
- ]
476
- ```
477
-
478
- `file` is the path bun reports, relative to the package root, and `test`
479
- is the name bun's console prints: the describe blocks and the test name
480
- joined by ` > `. `reason` is required. `when` is `ci` or `local` for a
481
- test skipped only there, and a declaration without it holds in both;
482
- `checks-test` counts a run as `ci` when `CI` is set true, as GitHub
483
- Actions sets it. A declaration that holds for the run but matches no
484
- skipped test fails a ci run too, so a fixed or renamed test takes its
485
- declaration with it. A local run only warns about it, because whether a
486
- test skips there can hang on the machine, such as a docker daemon being
487
- up:
488
-
489
- ```
490
- checks-test: 1 skipped test(s) undeclared and 1 declaration(s) matching no skipped test in this ci run:
491
- tests/pricing.test.ts:12 pricing > rounds half to even: skipped with no declaration; run it, or declare it in package.json testSkips with its reason
492
- tests/e2e/docker.test.ts > images > builds the release image: declared, but no such test skipped; delete the declaration
493
- ```
494
-
495
- It exits 1 when a test failed or a skip is undeclared or, in a ci run,
496
- a declaration stale, and 2 when `testSkips` does not parse or bun
497
- passed without writing its report. It takes no arguments: a `-t`
498
- filter reports every test it leaves out as skipped and a path filter
499
- drops files a declaration names, so a narrowed run is plain
500
- `bun test --randomize` with the arguments. Files under
501
- `tests/quarantine/` are never run and so never reported; see "Test
502
- layout".
503
-
504
- ## Flake run
505
-
506
- A green run proves nothing failed in that run, not that no test is
507
- flaky. `checks-flake` runs the whole suite several times, each with its
508
- own `--seed`, and records per failing test the seeds it failed with:
509
-
510
- ```sh
511
- checks-flake [--runs <count> | --seed <seed>...] [--report <file>]
512
- ```
513
-
514
- `--runs` defaults to 10 runs on random seeds, and `--seed`, given once
515
- per run, replays chosen seeds, such as the ones a report recorded.
516
- `bun test --randomize --seed=<seed>` puts the suite in the same order,
517
- so a seed reproduces a failure that hangs on order. `--report` writes the
518
- record as JSON, every run's seed and failing tests and every failing
519
- test's seeds, and under GitHub Actions the summary below is appended to
520
- the job summary:
521
-
522
- ```
523
- checks-flake: 3 of 10 run(s) failed, 1 test(s) failing in them
524
-
525
- | Test | Failed | Seeds |
526
- | --- | --- | --- |
527
- | tests/cache.test.ts:6 reads the cache | 3 of 10 runs | 2170533150, 4046124386, 180394251 |
528
-
529
- Reproduce a failing run with bun test --randomize --seed=<seed>.
530
- ```
531
-
532
- A run that fails with no failing test, such as a test file that throws
533
- while loading, is listed with its seed on its own line. It exits 1 when
534
- any run failed and 2 when bun passed without writing its report.
535
-
536
- A consumer runs it on a schedule and keeps the record as an artifact:
537
-
538
- ```yaml
539
- on:
540
- schedule:
541
- - cron: "17 5 * * *"
542
- workflow_dispatch:
543
- jobs:
544
- flake:
545
- runs-on: ubuntu-latest
546
- steps:
547
- - uses: actions/checkout@v5
548
- - uses: oven-sh/setup-bun@v2
549
- - run: bun install --frozen-lockfile
550
- - run: bunx checks-flake --runs 10 --report flake-report.json
551
- - uses: actions/upload-artifact@v4
552
- if: always()
553
- with:
554
- name: flake-report
555
- path: flake-report.json
556
- ```
557
-
558
- and declares the step in `gates.scheduled`, so `checks-ci-wiring`
559
- fails once the schedule stops running it; see "CI wiring".
560
-
561
- ## Dependency rules
562
-
563
- `.dependency-cruiser.cjs` extends the shared base, which carries
564
- `no-circular`, `no-orphans`, `not-to-dev-dep` (shipped source importing
565
- a dev-only package, which a package listed in `peerDependencies` too is
566
- not), `not-to-unresolvable` (nothing installed answers the
567
- specifier), and `no-deep-imports` (a subpath the package's exports map
568
- does not publish):
569
-
570
- ```js
571
- module.exports = {
572
- extends: "./node_modules/@avi2dg/checks/dependency-cruiser.config.js",
573
- forbidden: [
574
- {
575
- name: "ui-cannot-reach-server",
576
- severity: "error",
577
- from: { path: "^src/ui" },
578
- to: { path: "^src/server" },
579
- },
580
- ],
581
- };
582
- ```
583
-
584
- That last block is the layer-boundary recipe: append a named rule per
585
- boundary you own. A rule that restates a base name overrides it field
586
- by field, which is how an entry point stops being an orphan:
587
- redeclare `no-orphans` with your entry added to its `pathNot`.
588
- This repo's own `.dependency-cruiser.cjs` does that for the plugin
589
- entry. A repository that declares feature owners spreads the rules
590
- `quality.json` compiles to into the same `forbidden`; see "Feature
591
- owners".
592
-
593
- `package.json` gains the script:
594
-
595
- ```json
596
- "lint:deps": "depcruise --config .dependency-cruiser.cjs src"
597
- ```
598
-
599
- Enforcement runs in CI beside the other checks:
600
-
601
- ```yaml
602
- jobs:
603
- lint:
604
- steps:
605
- - uses: actions/checkout@v5
606
- - uses: oven-sh/setup-bun@v2
607
- - run: bun install --frozen-lockfile
608
- - run: bun run lint:deps
609
- ```
610
-
611
- ## Commit lint
612
-
613
- Commits follow `@commitlint/config-conventional` plus the house
614
- prefixes listed in `commitlint.config.js`, shared from
615
- `@avi2dg/checks/commitlint.config.js`. It arrives with the registry
616
- dependency above, since `@commitlint/cli` and
617
- `@commitlint/config-conventional` are dependencies, not peers.
618
- Enforcement runs in CI on pull requests, because `jj` never fires a
619
- git hook. Add this workflow to the consuming repo:
620
-
621
- ```yaml
622
- on:
623
- pull_request:
624
- types: [opened, edited, synchronize, reopened]
625
- jobs:
626
- commitlint:
627
- runs-on: ubuntu-latest
628
- steps:
629
- - uses: actions/checkout@v5
630
- - uses: oven-sh/setup-bun@v2
631
- - run: bun install --frozen-lockfile
632
- - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
633
- env:
634
- PR_TITLE: ${{ github.event.pull_request.title }}
635
- - run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
636
- ```
637
-
638
- It lints the pull request title and nothing else. The title is the
639
- enforced subject because squash merges use the PR title as the main
640
- commit subject; per-commit messages are not linted. GitHub appends
641
- ` (#N)` to the squashed subject, so the workflow lints the title with
642
- that suffix attached and the header length limit applies to the landed
643
- subject, not the bare title.
644
-
645
- It never sees a commit's author or committer fields, nor the
646
- `Co-authored-by` trailer GitHub writes from a foreign author when it
647
- squashes, so it cannot enforce who a commit belongs to. The
648
- commit-identity check below is the enforcement.
649
-
650
- ## Commit identity
651
-
652
- `scripts/commit-identity.ts` walks every commit in a range and fails when
653
- one carries an identity other than the repository owner's:
654
-
655
- ```sh
656
- bun run checks-commit-identity <base-ref> <head-ref>
657
- bun run checks-commit-identity <ref>
658
- ```
659
-
660
- With one argument it checks that commit alone. It refuses a commit
661
- whose author or committer is outside the allowlist, and one whose
662
- trailer block carries a `Co-authored-by:` trailer as git parses it, and
663
- it names the offending commit and reason. Other trailers and prose mentioning an address in the
664
- body are left alone. `GitHub <noreply@github.com>` is allowed as
665
- committer only, since that is who writes a squash merge.
666
-
667
- The allowlist defaults to `avi2d <avi2dg@gmail.com>`. A repo with other
668
- owners restates it in `quality.json`:
669
-
670
- ```json
671
- "commitIdentity": {
672
- "authors": [{ "name": "avi2d", "email": "avi2dg@gmail.com" }]
673
- }
674
- ```
675
-
676
- `checks-lint` runs it over each pull request's range; see "Lint entry point".
677
-
678
- ## Comment gate
679
-
680
- `scripts/comment-gate.ts` runs the comment check over a diff and fails
681
- when an added line carries a banned comment:
682
-
683
- ```sh
684
- bun run checks-comment-gate <base-ref> <head-ref>
685
- bun run checks-comment-gate <ref>
686
- ```
687
-
688
- With two arguments it diffs the base against the head. With one it diffs
689
- that commit against its parent, or against the empty tree for a
690
- repository's first commit, which has none. It exits 2 when the parent
691
- exists but is not in the clone rather than widening to the whole tree,
692
- so a shallow checkout running the one-argument form needs
693
- `fetch-depth: 2`. Only added lines
694
- are checked, so a violation in a file the diff never touches stays
695
- silent, and a refusal counts when any line of the comment carrying it was
696
- added. The check refuses a machine-read directive, a record or ticket
697
- pointer, a doc block, and a file opening with a rationale block over
698
- three lines, licence headers excepted.
699
-
700
- `scripts/comment-matchers.ts` holds the scanner, the comment syntaxes
701
- and a synchronous `refused()`, and imports nothing, so a host such as a
702
- hook bundle can copy it alone into a directory with no `node_modules`
703
- and import it as `@avi2dg/checks/scripts/comment-matchers.ts`. The repo's
704
- cruise fails when it gains an import. `scripts/comments.ts` wraps the
705
- same matchers in Effect for the gate and the backtest.
706
-
707
- `checks-lint` runs it over each pull request's range; see "Lint entry point".
708
-
709
- ## Suppressions ratchet
710
-
711
- `scripts/suppressions-ratchet.ts` holds oxlint's bulk-suppression
712
- baseline, `oxlint-suppressions.json`, to counts that only fall. oxlint
713
- accepts whatever `oxlint --suppress-all` writes, so raising a count to
714
- let a new site through passes the lint. The ratchet reads the file at a
715
- base and a head and fails naming each file and rule whose count rose or
716
- that appeared:
717
-
718
- ```sh
719
- bun run checks-suppressions-ratchet <base-ref> <head-ref>
720
- bun run checks-suppressions-ratchet <ref>
721
- ```
722
-
723
- With one argument it compares that commit with its parent, or with the
724
- empty tree for a repository's first commit, and exits 2 when the parent
725
- exists but is not in the clone. With two it reads the base where
726
- the head branched off, at their merge-base, so a count the base branch
727
- lowered since does not read as a rise at the head. A count that fell and
728
- an entry that went both pass. A commit without the file counts as empty,
729
- so the commit that first adds a baseline fails with every entry
730
- appearing. The file is read from the directory the command runs in,
731
- which is where oxlint writes it. It exits 2 when a ref does not resolve
732
- or the file is not oxlint's count per rule per file:
733
-
734
- ```
735
- suppressions-ratchet: 2 count(s) in oxlint-suppressions.json rose or appeared; fix the site instead of suppressing it:
736
- src/added.ts typescript/no-unsafe-type-assertion appeared with 1
737
- src/dispatch.ts typescript/no-non-null-assertion rose from 12 to 13
738
- ```
739
-
740
- `checks-lint` runs it over each pull request's range; see "Lint entry point".
741
-
742
- ## Size budget
743
-
744
- `checks-size-budget` holds production files to the line budget
745
- `quality.json` declares, and lists every other file over it without
746
- failing:
747
-
748
- ```json
749
- "sources": { "production": ["src/**/*.ts"] },
750
- "size": { "fileLines": 400, "functionLines": 100, "applies": "changed" }
751
- ```
752
-
753
- ```sh
754
- checks-size-budget <base-ref> <head-ref>
755
- checks-size-budget <ref>
756
- ```
757
-
758
- It runs oxlint with a configuration of two rules and nothing else:
759
- `max-lines` at `fileLines` and `max-lines-per-function` at
760
- `functionLines`, both counting blank and comment lines. With `applies`
761
- set to `changed` it holds the files under `sources.production` that the
762
- range adds or changes, a rename that edits the file included. With
763
- `all` it holds every file under `sources.production`. A file the range
764
- deletes or only renames is not held. Every other tracked `.ts` or
765
- `.tsx` file over the budget, tests and unchanged production files alike,
766
- is listed as advisory and never fails the gate; `.d.ts` files are not
767
- measured.
768
-
769
- It reads each file from the head commit rather than the working tree,
770
- so an uncommitted edit neither fails nor passes a range, and a pull
771
- request's merge checkout measures what the pull request holds. With two
772
- arguments the range starts where the head branched from the base, at
773
- their merge-base. With one it is that commit against its parent, or
774
- against the empty tree for a repository's first commit. oxlint must be
775
- on `PATH`, as it is under a package script.
776
-
777
- ```
778
- size-budget: 1 overrun(s) of 400 lines per file and 100 per function in the production files the range adds or changes:
779
- src/billing/ledger.ts:12: The function `settle` has too many lines (131). Maximum allowed is 100.
780
- size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
781
- tests/e2e/billing.test.ts: File has too many lines (512).
782
- ```
783
-
784
- It exits 1 on an overrun in a file it holds, and 2 when `quality.json`
785
- does not decode, a ref does not resolve or oxlint cannot run. A
786
- repository that declares no `size` passes. `quality.json` refuses a
787
- `size` without `sources.production`, which would hold nothing, and
788
- with `size` declared `checks-quality` refuses a `sources.production`
789
- glob that matches no file. Moving `applies` from `changed` to `all`
790
- tightens the budget to every production file, once the advisory list
791
- names none.
792
-
793
- `checks-lint` runs it over each pull request's range; see "Lint entry point".
794
-
795
- ## Feature owners
796
-
797
- A repository opts a feature in by declaring, in `quality.json`, the
798
- directory it owns, the files code outside it imports it through, the
799
- files that may reach past those, and the end-to-end test that proves it
800
- runs:
801
-
802
- ```json
803
- "features": [
804
- {
805
- "name": "billing",
806
- "root": "src/billing",
807
- "entries": ["src/billing/index.ts"],
808
- "allowFrom": ["src/main.ts", "src/cli/*.ts"],
809
- "proof": "tests/e2e/billing.test.ts"
810
- }
811
- ],
812
- "changeSignal": "advisory"
813
- ```
814
-
815
- `root` is a directory and `entries` are files under it, both without
816
- globs. `allowFrom` holds globs of the same shape as `sources`. `proof`
817
- is a `.test.ts` or `.test.tsx` file under `tests/e2e/`. Nothing moves:
818
- a root is wherever the feature already lives. `quality.json` refuses an
819
- entry outside its root, a name used twice, and two features sharing a
820
- root or one root inside another, so a file has at most one owner.
821
-
822
- ### Import boundary
823
-
824
- `dist/feature-rules.js` compiles `features` into one dependency-cruiser
825
- rule per feature, which `.dependency-cruiser.cjs` spreads beside its
826
- own:
827
-
828
- ```js
829
- const { featureRules } = require("@avi2dg/checks/dist/feature-rules.js");
830
-
831
- module.exports = {
832
- extends: "./node_modules/@avi2dg/checks/dependency-cruiser.config.js",
833
- forbidden: [...featureRules(require("./quality.json"))],
834
- };
835
- ```
836
-
837
- A module outside a feature's root that imports a file inside it must
838
- import one of the feature's `entries`. Modules under `tests/` and the
839
- files `allowFrom` matches, such as a CLI or a harness, may import any
840
- file in it:
841
-
842
- ```
843
- error feature-billing-entries: src/report.ts → src/billing/charge.ts
844
- ```
845
-
846
- `featureRules` decodes its argument with the schema the bins use and
847
- throws the schema's refusal when it does not decode, which stops the
848
- cruise. It is an ES module, as `effect` is, so a `.cjs` config loads it
849
- through `require`, which needs node 20.19, 22.12 or later.
850
-
851
- ### Change signal and proof
852
-
853
- `checks-feature-owners` reads the same declaration over a range:
854
-
855
- ```sh
856
- checks-feature-owners <base-ref> <head-ref>
857
- checks-feature-owners <ref>
858
- ```
859
-
860
- It exits 1 when a feature's proof cannot prove it: the proof or an
861
- entry is not in the head commit, the proof does not parse, or it
862
- imports none of the feature's entries. An import counts when it is a
863
- runtime `import`, `export ... from` or `export * from` of a relative
864
- path that names an entry: by its own name, by the `.js`, `.jsx`, `.mjs`
865
- or `.cjs` spelling of it, `.js` naming a `.tsx` entry as well as a
866
- `.ts` one, or without an extension, the way a directory `index` is
867
- imported. `import type` does not count, and neither does a
868
- path alias. The proof runs in `bun run test` like any end-to-end test,
869
- which is what shows it passes.
870
-
871
- ```
872
- feature-owners: 1 problem(s) with the features' runnable proofs:
873
- billing: proof tests/e2e/billing.test.ts imports none of its entries, src/billing/index.ts
874
- ```
875
-
876
- With `changeSignal` set to `advisory` it also lists each owner the
877
- range touches, with the paths it touched under the owner's root or at
878
- its proof, and still exits 0. Whether a change that spans owners is one
879
- coherent slice is for a reviewer to judge. A rename counts at both of
880
- its paths:
881
-
882
- ```
883
- feature-owners: advisory, the range touches 2 feature owner(s); a reviewer judges whether they make one slice:
884
- billing: src/billing/charge.ts, src/billing/tax.ts
885
- invoices: src/invoices/tax.ts, tests/e2e/invoices.test.ts
886
- ```
887
-
888
- It exits 2 when `quality.json` does not decode, which is where a proof
889
- outside `tests/e2e/` is refused, or a ref does not resolve. A
890
- repository that declares no feature passes, and `quality.json` refuses
891
- a `changeSignal` without features, which would map a change to no
892
- owner.
893
-
894
- `checks-lint` runs it over each pull request's range; see "Lint entry point".
895
-
896
- ## CI wiring
897
-
898
- `checks-ci-wiring` fails when a command the repository's CI must run no
899
- longer runs on pull requests to the default branch. No local check sees
900
- that: a workflow whose lint step became a no-op leaves `bun run lint`
901
- green.
902
-
903
- The repository declares its gates once, in `quality.json`:
904
-
905
- ```json
906
- "gates": {
907
- "ci": ["bun run lint", "bun run typecheck", "bun run test"]
908
- }
909
- ```
910
-
911
- and `package.json` runs the check through `checks-lint`:
912
-
913
- ```json
914
- "scripts": {
915
- "lint": "oxlint --type-aware && checks-lint"
916
- }
917
- ```
918
-
919
- It parses every `.github/workflows/*.yml` and `*.yaml` and looks, for
920
- each gate, for a `run:` step that is the gate command alone on one line,
921
- optionally followed by plain arguments: words, quoted strings, and
922
- `$VAR` or `${VAR}` expansions. `bun run lint --quiet` and
923
- `bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"` count;
924
- `bun run lint:deps`, `echo bun run lint` and a step `name:` do not. A
925
- step whose script has a second line, or any `|`, `||`, `&&`, `;`, `&`,
926
- `$(...)`, backticks, `<` or `>` redirection, a comment or a leading
927
- `NAME=value`, never counts, because each can run the gate without its
928
- failure failing the step; the report names the gate and says to give it
929
- its own step with nothing else in it. A gate step counts only when:
930
-
931
- - its workflow triggers on `pull_request`, any `branches` or
932
- `branches-ignore` filter there keeps the default branch, any `types`
933
- filter keeps `opened` and `synchronize`, and it sets no `paths` or
934
- `paths-ignore` filter, which lets some pull requests skip the gate;
935
- - neither the step nor its job sets `if: false` or
936
- `continue-on-error: true`, bare or as `${{ false }}` and `${{ true }}`;
937
- - its job needs no job, directly or through a chain, that sets
938
- `if: false`, unless a job on that chain has an `if:` calling
939
- `always()`, `failure()` or `cancelled()`. GitHub prefixes every other
940
- `if:`, including `true` and `success()`, with `success()`, so a job
941
- whose needed job was skipped is skipped too.
942
-
943
- A job calling a local reusable workflow (`uses: ./.github/workflows/x.yml`)
944
- passes its own trigger and `if:` down to the called workflow's steps.
945
- A remote reusable workflow (`uses: owner/repo/...@ref`) is not a
946
- supported way to wire a gate: it is not read, so a gate must run as a
947
- `run:` step, such as `bunx checks-comment-gate`, in the repo's own
948
- workflows.
949
-
950
- A step running `checks-lint` also counts for a declared gate that calls
951
- one of the gates `checks-lint` runs by its bare bin name, when the step
952
- calls `checks-lint` the same way: `bunx checks-lint` counts for
953
- `bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"`. A step running `bun run lint` counts only for the `bun run lint` gate,
954
- since the check never reads what a package script runs. So once `lint`
955
- runs `checks-lint`, the per-gate entries can leave `gates.ci`
956
- along with the workflows that ran them.
957
-
958
- The default branch is `main`; a repo with another one sets
959
- `"defaultBranch"` in `quality.json`, which `checks-lint` also reads when
960
- `origin/HEAD` is not set.
961
-
962
- It exits 1 naming each gap, with every step that runs the gate and why
963
- that step does not count:
964
-
965
- ```
966
- ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
967
- bun run lint
968
- .github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
969
- ```
970
-
971
- It exits 2 when `quality.json` declares no `gates.ci` or does not
972
- decode, a gate or scheduled command is not one plain command, or a
973
- workflow does not parse. Whether a workflow is well formed is
974
- actionlint's question, not this one's.
975
-
976
- A command a schedule must run, such as the flake run, goes in
977
- `gates.scheduled`:
978
-
979
- ```json
980
- "gates": {
981
- "ci": ["bun run lint", "bun run typecheck", "bun run test"],
982
- "scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
983
- }
984
- ```
985
-
986
- Each counts only as a step of the same plain shape in a workflow whose
987
- `on` carries `schedule` with at least one `cron`, under the same
988
- `if: false`, `continue-on-error: true` and `needs` rules as a gate. It
989
- exits 1 naming each one no schedule runs:
990
-
991
- ```
992
- ci-wiring: 1 of 1 scheduled command(s) do not run on a schedule:
993
- bunx checks-flake --runs 10 --report flake-report.json
994
- .github/workflows/ci.yml job checks step 5: .github/workflows/ci.yml does not trigger on a schedule
995
- ```
996
-
997
- ### Gate selection
998
-
999
- A repository with no TypeScript source gives `checks-lint-coverage`,
1000
- `checks-test-layout`, `checks-size-budget` and `checks-feature-owners`
1001
- nothing to check, and test-layout still refuses its
1002
- missing `bun test` script and `bunfig.toml`. It declares the gates
1003
- `checks-lint` runs as `gates.lint`:
1004
-
1005
- ```json
1006
- "gates": {
1007
- "ci": ["bun run lint"],
1008
- "lint": [
1009
- "checks-commit-identity",
1010
- "checks-comment-gate",
1011
- "checks-suppressions-ratchet",
1012
- "checks-ci-wiring",
1013
- "checks-quality"
1014
- ]
1015
- }
1016
- ```
1017
-
1018
- `checks-lint` runs exactly those, in the "Lint entry point" table's
1019
- order, and all nine when `gates.lint` is absent. A selection in
1020
- `quality.json` always keeps `checks-quality`, since the file it sits in
1021
- is what makes that gate apply. A step running
1022
- `checks-lint` then counts only for a declared gate that `gates.lint`
1023
- keeps.
1024
-
1025
- A selection may leave out only a gate that does not apply:
1026
-
1027
- | Gate | Applies when the repository |
1028
- | --- | --- |
1029
- | `checks-lint-coverage` | tracks a `.ts` or `.tsx` file |
1030
- | `checks-test-layout` | tracks a `.ts` or `.tsx` file |
1031
- | `checks-commit-identity` | always |
1032
- | `checks-comment-gate` | always |
1033
- | `checks-suppressions-ratchet` | always |
1034
- | `checks-ci-wiring` | always |
1035
- | `checks-quality` | tracks a `quality.json` |
1036
- | `checks-size-budget` | tracks a `.ts` or `.tsx` file |
1037
- | `checks-feature-owners` | tracks a `.ts` or `.tsx` file |
1038
-
1039
- Both bins exit 2 on a `gates.lint` that names an unknown gate or leaves
1040
- out one that always applies. ci-wiring exits 1 when the
1041
- selection leaves out a gate the repository's tracked files make
1042
- applicable, and names the gate and the files:
1043
-
1044
- ```
1045
- ci-wiring: quality.json gates.lint leaves out 4 gate(s) this repository's contents make applicable:
1046
- checks-lint-coverage: the repository tracks TypeScript source (src/widget.ts)
1047
- checks-test-layout: the repository tracks TypeScript source (src/widget.ts)
1048
- checks-size-budget: the repository tracks TypeScript source (src/widget.ts)
1049
- checks-feature-owners: the repository tracks TypeScript source (src/widget.ts)
1050
- ```
1051
-
1052
- It reads the files tracked at the checkout, so the pull request that
1053
- adds the first TypeScript file is the one refused.
1054
-
1055
- ### Limits
1056
-
1057
- The check reads workflow files and never runs them, so it deliberately
1058
- does not evaluate:
1059
-
1060
- - an `if:` expression other than a constant `true` or `false`, which
1061
- counts as running;
1062
- - a `strategy.matrix` `include` or `exclude`, so a matrix that drops
1063
- every combination still counts as running its steps;
1064
- - a remote reusable workflow (`uses: owner/repo/...@ref`), whose steps
1065
- are never read;
1066
- - anything that happens at run time on the runner: what the gate
1067
- command itself does, the shell's options, and a step or job that
1068
- fails or times out before the gate step.
1069
-
1070
- ## Backtest
1071
-
1072
- `scripts/backtest.ts` reports what the comment check would have refused
1073
- at each recent commit, so a repository can measure its own history:
1074
-
1075
- ```sh
1076
- bun run checks-backtest [commit-count]
1077
- ```
1078
-
1079
- It walks the last `commit-count` first-parent commits, 60 by default,
1080
- prints a row per commit that touches code and then the totals,
1081
- attributes only the refusals each commit introduced, and counts new
1082
- comment text as a share of added lines.
1083
- `generated/`, `vendor/`, `repos/`, `node_modules/` and `dist/` are out
1084
- of reach, so the figures are authored code.
1085
-
1086
- ## Mutation compare
1087
-
1088
- `checks-mutation-compare` gates a pull request on no-regression rather
1089
- than an absolute threshold: the head mutation score may not fall below
1090
- the score at the merge-base.
1091
-
1092
- ```sh
1093
- checks-mutation-compare <base-report> <head-report> [--advisory]
1094
- ```
1095
-
1096
- Both reports are Stryker `mutation.json` files. It prints the overall
1097
- score of each report and the per-file scores that differ, and exits 1
1098
- when the head score is below the base score. The score is Stryker's:
1099
- `Killed` and `Timeout` over those plus `Survived` and `NoCoverage`, so
1100
- `CompileError`, `RuntimeError`, `Ignored` and `Pending` mutants leave
1101
- it. Every file in a report counts toward that report's score, including
1102
- files present in only one of the two.
1103
-
1104
- `--advisory` prints the same verdict and always exits 0. That is how
1105
- consumers run it for the first month; after that they drop the flag and
1106
- it blocks.
1107
-
1108
- Enforcement runs on pull requests, comparing the head report against a
1109
- report built at the merge-base:
1110
-
1111
- ```yaml
1112
- jobs:
1113
- mutation-compare:
1114
- runs-on: ubuntu-latest
1115
- steps:
1116
- - uses: actions/checkout@v5
1117
- with:
1118
- fetch-depth: 0
1119
- - uses: oven-sh/setup-bun@v2
1120
- - run: bun install --frozen-lockfile
1121
- - run: bunx stryker run
1122
- - run: |
1123
- base="$(git merge-base HEAD origin/main)"
1124
- git worktree add /tmp/mutation-base "$base"
1125
- (cd /tmp/mutation-base && bun install --frozen-lockfile && bunx stryker run)
1126
- - run: bun run checks-mutation-compare --advisory /tmp/mutation-base/reports/mutation/mutation.json reports/mutation/mutation.json
1127
- ```
1128
-
1129
- The shared Stryker preset's `json` reporter writes
1130
- `reports/mutation/mutation.json` in each worktree.
1131
-
1132
- ## Why it is shaped this way
1133
-
1134
- - Every config in an oxlint `extends` chain brings its own `plugins`,
1135
- and one that sets none brings oxlint's default plugins, whose
1136
- category rules the base's `categories` then turn on across the tree.
1137
- `rules`, `categories` and `jsPlugins` inherit as expected. That is why
1138
- the consumer snippet restates `plugins` and nothing else, and why the
1139
- generated fragment always sets them.
1140
- - `node_modules/` is excluded through the consumer's `.gitignore`, not
1141
- `ignorePatterns`: oxlint still walks the installed package when only
1142
- `ignorePatterns` names it.
1143
- - `files` in package.json is the published surface: `tests/`, `AGENTS.md`
1144
- and the `.ts` plugin source never reach an install. npm adds
1145
- `package.json`, `README` and `LICENSE` to the tarball whatever `files`
1146
- says. `bun pm pack` builds the same tarball the registry serves, which
1147
- is what the packed-tarball consumer e2e test installs.
1148
- - The plugin ships compiled as `dist/index.js`, built with
1149
- `bun build effect-channel/index.ts --outdir dist --target node --format esm`.
1150
- Node refuses to type-strip a `.ts` plugin under `node_modules`, so the
1151
- `.ts` source would fail to load from an installed package.
1152
- - `featureRules` ships compiled as `dist/feature-rules.js` for the same
1153
- reason, with `effect` left out of the bundle so it resolves the
1154
- consumer's own copy. dependency-cruiser uses a config's export as it
1155
- is and never awaits it, so the declaration decodes synchronously, and
1156
- `quality.json` exempts that one file from the Effect rules.
1157
- - `checks-size-budget` writes the head commit's files to a temporary
1158
- directory and runs oxlint there, with a configuration that sets no
1159
- plugin and turns every category off, so the consumer's own
1160
- `.oxlintrc.json`, its ignore files and its other rules never reach the
1161
- count.
1162
- - `dist/` is committed. No `prepack` or `prepublishOnly` builds it, so a
1163
- publish ships whatever bundle the publishing worktree holds. Rebuild it
1164
- after pulling with `bun run build`; CI fails when the committed bundle
1165
- is stale. `bun run build` also emits `quality.schema.json`, which is
1166
- committed the same way, and a test fails when it differs from what
1167
- the schema emits.
1168
- - `quality.json` is JSON, not TOML or a TypeScript module: a bun bin, a
1169
- hook running without `node_modules`, a `.cjs` or `.mjs` config and
1170
- `jq` all parse it with nothing installed, and nobody runs a
1171
- repository's own code to learn its policy. It holds declarations
1172
- only. The kit's bins read it directly; oxlint and tsc read nothing but
1173
- their own JSON, so they extend generated fragments, which
1174
- `checks-quality --check` holds to the declarations.
1175
- - `quality.json` refuses a key its schema does not name, so a kit that
1176
- cannot enforce a newer key refuses it rather than let the repository
1177
- believe it enforced.
1178
- - The base parses with swc because typescript 7 (tsgo) has no compiler
1179
- API for dependency-cruiser to use. Without `@swc/core` installed the
1180
- cruise silently skips every `.ts` file, so this repo's test asserts its
1181
- own TypeScript is cruised.
1182
- - `bunfig.toml` has no `extends` and no include: bun ignores an unknown
1183
- top-level key in silence, so a preset cannot be inherited and the
1184
- consumer's copy is compared key by key against the installed one
1185
- instead. `[test] pathIgnorePatterns` is a real bunfig key, and an empty
1186
- `--path-ignore-patterns` flag overrides the file's own list.
1187
- - The Stryker preset is a JavaScript module, not JSON: Stryker 10 does
1188
- not resolve `extends` in a JSON config, but a `.mjs` config that
1189
- spreads an imported object consumes it. Keys the consumer sets after
1190
- the spread win.
1191
- - The `.ts` bins are written in Effect, so `effect` is a peer dependency
1192
- and `@effect/platform-bun`, which only the bins use, is a dependency.
1193
- `@effect/platform-node-shared` is a direct dependency at the same exact
1194
- version only to pin it: `@effect/platform-bun` asks for it with a `^`
1195
- range, and a newer rc peers on a newer `effect` than consumers install,
1196
- so all three move together.
1197
- - Each runnable script ships a `checks-` bin entry, so consumer
1198
- `package.json` scripts call the short name, which the package manager
1199
- puts on `PATH` only there; a shell runs it through `bun run`, which
1200
- never falls back to the registry the way `bunx` does. The `.ts` checks
1201
- keep a `bun` shebang, which needs no build step and no `dist/`
1202
- entry, unlike the oxlint plugin that node loads.
1203
- - `checks-lint` runs each gate as its own bin in a child process rather
1204
- than importing it, so a gate behaves the same called alone or through
1205
- the entry point, and `lint-coverage.sh` stays a shell script. The
1206
- gates run one at a time with their output passed straight through, so
1207
- each report reads whole and in the table's order.
1208
- - A gate selection is checked against the repository's contents rather
1209
- than trusted, so it cannot skip a gate that applies. ci-wiring does
1210
- that check, which is why a selection without it, or without another
1211
- gate that applies everywhere, is refused as `checks-lint` reads it:
1212
- nothing would check the selection otherwise.
1213
- - `checks-test` runs bun itself rather than reading a report some other
1214
- run left: a skip taken only on CI is visible only in CI's own run, and
1215
- an earlier run's report may be stale or narrowed. It reads the JUnit
1216
- report bun writes to a temporary directory, since bun has no other
1217
- per-test output meant for a program.
1218
- - `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
1219
- deleting the step that runs a check is the violation it catches, so the
1220
- local `lint` is where it has to fail.
1221
- - Workflows are parsed with `Bun.YAML`, which the `bun` shebang already
1222
- provides, so the check adds no dependency. It reads `on` as a string
1223
- key, not as the YAML 1.1 boolean.
1224
- - `bun` counts as a built-in module. Nothing installed resolves it except
1225
- `@types/bun`, which would otherwise make every runtime `bun` import look
1226
- like a dev-only dependency.
1227
- - The pull request merge commit GitHub builds is authored by `GitHub
1228
- <noreply@github.com>`, which commit-identity refuses as an author.
1229
- `checks-lint` ends a pull request's range at the event's head sha, so
1230
- the merge commit is never in it. A `lint` that calls
1231
- `checks-commit-identity HEAD` itself checks out
1232
- `github.event.pull_request.head.sha` instead of the default merge ref.
1233
- - `no-deep-imports` judges the import specifier, never the resolved file.
1234
- The base honours `exports` maps, so a subpath the map publishes resolves
1235
- and passes, one it omits fails to resolve and is reported, and a package
1236
- without an `exports` map publishes every file. A bare import always
1237
- passes whatever file its entry lives in. Setting your own
1238
- `options.enhancedResolveOptions` replaces the base's, so restate
1239
- `exportsFields` and `conditionNames` if you do.
1240
- - dependency-cruiser `extends` merges same-name `forbidden` rules with the
1241
- child's fields winning. That is the entry-point and layer recipe above.
1242
-
1243
- ## Develop
1244
-
1245
- ```sh
1246
- bun install
1247
- bun run build
1248
- bun run lint
1249
- bun run typecheck
1250
- bun run test
1251
- ```
1252
-
1253
- Release by tagging a commit on `main`. The `release` workflow refuses
1254
- a tag off `main` and reruns the build, `dist/` check, lint, typecheck
1255
- and tests before it publishes:
1256
-
1257
- ```sh
1258
- tag="v$(bun -p 'require("./package.json").version')"
1259
- git tag "$tag" && git push origin "$tag"
1260
- ```
1261
-
1262
- The `release` workflow publishes the tagged version through npm
1263
- trusted publishing, so the tag and `package.json` must agree. No
1264
- token is stored anywhere: GitHub mints the publish credential for
1265
- each run.
1266
-
1267
- npm attaches a trusted publisher only to a package that already
1268
- exists, so the first version goes out by hand: `npm publish` from the
1269
- tagged commit as `avi2dg`, then add the trusted publisher (repository
1270
- `avi2d/checks`, workflow `release.yml`) in the package's npm settings.
1271
- That first tag's `release` run fails on the already-published version;
1272
- every later tag publishes through the workflow.
1273
-
1274
- `publishConfig.access` in package.json is what makes the scoped package
1275
- public.
158
+ | `CHANGELOG.md` | every release, and what it changed |
159
+ | `CONTRIBUTING.md` | how this repository is developed and released |
160
+ | `docs/` | a reference page per bin and per shared config, and why the kit is shaped this way |
161
+ | `bunfig.toml` | the bunfig preset a repository copies |
162
+ | `commitlint.config.js` | the shared commitlint config |
163
+ | `dependency-cruiser.config.js` | the shared dependency-cruiser base |
164
+ | `scripts/` | every bin, which a package script calls by its `checks-` name |
165
+ | `templates/` | one template per kind of doc file, which a new doc file starts from |
166
+ | `presets/` | the Effect rule blocks `checks-quality generate` writes into the fragments |
167
+ | `quality.schema.json` | the schema of `quality.json`, which its `$schema` line names |
168
+ | `oxlintrc.json` | the oxlint base config `.oxlintrc.json` extends |
169
+ | `stryker.preset.js` | the Stryker mutation-testing preset |
170
+ | `tsconfig.effect.json` | the tsconfig fragment with the Effect language-service block |
171
+ | `dist/` | the compiled Effect error-channel plugin and `featureRules` |
172
+
173
+ <!-- end generated shipped -->
174
+
175
+ ## Related topics
176
+
177
+ - [The quality file](docs/configs/quality-file.md)
178
+ - [The Effect rules](docs/configs/effect-rules.md)
179
+ - [The dependency rules](docs/configs/dependency-rules.md)
180
+ - [The commit message lint](docs/configs/commit-messages.md)
181
+ - [Why it is shaped this way](docs/design.md)
182
+ - [Contribute to checks](CONTRIBUTING.md)