@avi2dg/checks 0.10.0 → 0.12.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.
package/README.md CHANGED
@@ -6,9 +6,12 @@ below over a range it resolves itself, the `checks-test` entry point
6
6
  that runs the suite and refuses an undeclared skip, the `checks-flake`
7
7
  run that records the seeds a failing test fails with, the oxlint base
8
8
  config, the tsconfig fragment with the Effect language-service block,
9
- the shared commitlint config, the shared dependency-cruiser base, the
10
- test-layout check with its bunfig preset, the commit-identity check, the
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
11
13
  comment gate with its backtest, the oxlint suppressions ratchet, the
14
+ size budget, the feature-owner change signal and proof check, the
12
15
  Stryker mutation-testing preset with its no-regression comparator, the
13
16
  CI-wiring check, and the Effect error-channel plugin compiled to
14
17
  JavaScript.
@@ -46,6 +49,17 @@ node_modules/
46
49
  }
47
50
  ```
48
51
 
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:
55
+
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
+ ```
62
+
49
63
  `bunfig.toml` is a copy of the shipped preset:
50
64
 
51
65
  ```sh
@@ -92,12 +106,164 @@ export default {
92
106
  The registry version is pinned by the consumer's lockfile; bump
93
107
  `@avi2dg/checks` to adopt a new release.
94
108
 
109
+ ## Quality file
110
+
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:
113
+
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
+ ```
141
+
142
+ | Key | Read by | Holds |
143
+ | --- | --- | --- |
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 |
155
+
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.
173
+
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:
178
+
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
+ ```
207
+
208
+ `tsconfig.json`:
209
+
210
+ ```json
211
+ {
212
+ "extends": ["@avi2dg/checks/tsconfig.effect.json", "./tsconfig.quality.json"]
213
+ }
214
+ ```
215
+
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.
224
+
225
+ `checks-lint` runs `checks-quality --check`, which exits 1 when:
226
+
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.
236
+
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.
239
+
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
+ ```
245
+
246
+ Two details of the fragments are easy to get wrong, so the kit's tests
247
+ pin both:
248
+
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
+
95
261
  ## Lint entry point
96
262
 
97
263
  `checks-lint` runs each of the kit's lint gates in turn and names every
98
264
  one that fails, rather than stopping at the first. A repository whose
99
265
  tracked files give a gate nothing to check can leave it out through
100
- `ciWiring.lintGates`; see "Gate selection" under "CI wiring".
266
+ `gates.lint`; see "Gate selection" under "CI wiring".
101
267
 
102
268
  | Gate | Reads |
103
269
  | --- | --- |
@@ -107,6 +273,9 @@ tracked files give a gate nothing to check can leave it out through
107
273
  | `checks-comment-gate` | the range |
108
274
  | `checks-suppressions-ratchet` | the range |
109
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 |
110
279
 
111
280
  ```sh
112
281
  checks-lint
@@ -117,8 +286,8 @@ It resolves the range once and hands the same one to every range gate.
117
286
  Locally, and on any event other than a pull request, the range ends at
118
287
  `HEAD` and starts where `HEAD` branched from the origin default branch:
119
288
  `origin/HEAD`, or when `origin/HEAD` is not set, as in an
120
- `actions/checkout` clone, `origin/<ciWiring.defaultBranch>` from the
121
- repository's `package.json` (see "CI wiring"), and `origin/main` when
289
+ `actions/checkout` clone, `origin/<defaultBranch>` from the
290
+ repository's `quality.json` (see "Quality file"), and `origin/main` when
122
291
  that is not declared. In a GitHub Actions pull request, where
123
292
  `GITHUB_EVENT_NAME` is `pull_request`, it ends
124
293
  at the event's head sha and starts where that branched from
@@ -159,12 +328,12 @@ gate's own report, then its verdict:
159
328
  ```
160
329
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
161
330
  ...
162
- checks-lint: 3 of 6 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
331
+ checks-lint: 3 of 9 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
163
332
  ```
164
333
 
165
334
  It exits 1 when any gate found a violation, and 2 when the range or the
166
335
  selection does not resolve, or no failing gate could decide. ci-wiring
167
- always runs, so a repository on `checks-lint` declares `ciWiring.gates`
336
+ always runs, so a repository on `checks-lint` declares `gates.ci`
168
337
  (see "CI wiring"), and it holds the test layout unless its selection
169
338
  leaves out `checks-test-layout`.
170
339
 
@@ -205,69 +374,43 @@ Each refusal says what to write instead: a `Schema.TaggedError` failed
205
374
  through `Effect.fail`, a throwing call wrapped in `Effect.try` or
206
375
  `Effect.tryPromise`, and recovery by tag with `Effect.catchTag`.
207
376
 
208
- A repository turns them on for the paths it writes in Effect through an
209
- `overrides` entry in its root `.oxlintrc.json`:
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":
210
380
 
211
381
  ```json
212
- {
213
- "extends": ["./node_modules/@avi2dg/checks/oxlintrc.json"],
214
- "plugins": ["typescript", "oxc", "eslint", "import"],
215
- "overrides": [
216
- {
217
- "files": ["src/**"],
218
- "rules": {
219
- "effect-channel/no-throw": "error",
220
- "effect-channel/no-try-catch": "error"
221
- }
222
- }
223
- ]
382
+ "sources": {
383
+ "effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
224
384
  }
225
385
  ```
226
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
+
227
392
  oxlint resolves `files` against the directory of the config that holds
228
393
  the override, so a config passed with `-c` from outside the repository
229
394
  matches nothing and reports nothing.
230
395
 
231
- This repository's own override for `scripts/**` adds `node/no-sync`,
232
- `oxc/no-async-await`, `promise/avoid-new` and `unicorn/no-process-exit`,
233
- which need the `node`, `promise` and `unicorn` plugins in the override's
234
- `plugins`. `unicorn/no-process-exit` passes over any file that opens with
235
- a shebang, so a bin also needs `no-restricted-properties` on
236
- `process.exit`. Sites standing when the override lands go in oxlint's own
237
- baseline, `oxlint --suppress-all`, so their count can only fall. A later
238
- override lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`,
239
- the one file there a host loads without `node_modules`.
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`.
240
407
 
241
408
  The language service holds the same paths to Effect-native IO through
242
- `overrides` in the plugin block of `tsconfig.json`. effect-tsgo keeps the
243
- severities `tsconfig.effect.json` sets when the child config restates the
244
- plugin with only its overrides:
245
-
246
- ```json
247
- {
248
- "extends": "@avi2dg/checks/tsconfig.effect.json",
249
- "compilerOptions": {
250
- "plugins": [
251
- {
252
- "name": "@effect/language-service",
253
- "overrides": [
254
- {
255
- "include": ["src/**/*.ts"],
256
- "options": {
257
- "diagnosticSeverity": {
258
- "nodeBuiltinImport": "error",
259
- "asyncFunction": "error",
260
- "newPromise": "error",
261
- "extendsNativeError": "error"
262
- }
263
- }
264
- }
265
- ]
266
- }
267
- ]
268
- }
269
- }
270
- ```
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.
271
414
 
272
415
  ## Test layout
273
416
 
@@ -412,7 +555,7 @@ jobs:
412
555
  path: flake-report.json
413
556
  ```
414
557
 
415
- and declares the step in `ciWiring.scheduled`, so `checks-ci-wiring`
558
+ and declares the step in `gates.scheduled`, so `checks-ci-wiring`
416
559
  fails once the schedule stops running it; see "CI wiring".
417
560
 
418
561
  ## Dependency rules
@@ -443,7 +586,9 @@ boundary you own. A rule that restates a base name overrides it field
443
586
  by field, which is how an entry point stops being an orphan:
444
587
  redeclare `no-orphans` with your entry added to its `pathNot`.
445
588
  This repo's own `.dependency-cruiser.cjs` does that for the plugin
446
- entry.
589
+ entry. A repository that declares feature owners spreads the rules
590
+ `quality.json` compiles to into the same `forbidden`; see "Feature
591
+ owners".
447
592
 
448
593
  `package.json` gains the script:
449
594
 
@@ -520,7 +665,7 @@ body are left alone. `GitHub <noreply@github.com>` is allowed as
520
665
  committer only, since that is who writes a squash merge.
521
666
 
522
667
  The allowlist defaults to `avi2d <avi2dg@gmail.com>`. A repo with other
523
- owners restates it in `package.json`:
668
+ owners restates it in `quality.json`:
524
669
 
525
670
  ```json
526
671
  "commitIdentity": {
@@ -594,6 +739,160 @@ suppressions-ratchet: 2 count(s) in oxlint-suppressions.json rose or appeared; f
594
739
 
595
740
  `checks-lint` runs it over each pull request's range; see "Lint entry point".
596
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
+
597
896
  ## CI wiring
598
897
 
599
898
  `checks-ci-wiring` fails when a command the repository's CI must run no
@@ -601,13 +900,17 @@ longer runs on pull requests to the default branch. No local check sees
601
900
  that: a workflow whose lint step became a no-op leaves `bun run lint`
602
901
  green.
603
902
 
604
- The repository declares its gates once, in `package.json`, and `lint`
605
- runs the check through `checks-lint`:
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`:
606
912
 
607
913
  ```json
608
- "ciWiring": {
609
- "gates": ["bun run lint", "bun run typecheck", "bun run test"]
610
- },
611
914
  "scripts": {
612
915
  "lint": "oxlint --type-aware && checks-lint"
613
916
  }
@@ -649,11 +952,11 @@ one of the gates `checks-lint` runs by its bare bin name, when the step
649
952
  calls `checks-lint` the same way: `bunx checks-lint` counts for
650
953
  `bunx checks-comment-gate "origin/$BASE_REF" "$HEAD_SHA"`. A step running `bun run lint` counts only for the `bun run lint` gate,
651
954
  since the check never reads what a package script runs. So once `lint`
652
- runs `checks-lint`, the per-gate entries can leave `ciWiring.gates`
955
+ runs `checks-lint`, the per-gate entries can leave `gates.ci`
653
956
  along with the workflows that ran them.
654
957
 
655
958
  The default branch is `main`; a repo with another one sets
656
- `"defaultBranch"` beside `"gates"`, which `checks-lint` also reads when
959
+ `"defaultBranch"` in `quality.json`, which `checks-lint` also reads when
657
960
  `origin/HEAD` is not set.
658
961
 
659
962
  It exits 1 naming each gap, with every step that runs the gate and why
@@ -665,17 +968,17 @@ ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
665
968
  .github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
666
969
  ```
667
970
 
668
- It exits 2 when `package.json` declares no gates, `scheduled` is not an
669
- array, a gate or scheduled command is not one plain command, or a
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
670
973
  workflow does not parse. Whether a workflow is well formed is
671
974
  actionlint's question, not this one's.
672
975
 
673
976
  A command a schedule must run, such as the flake run, goes in
674
- `"scheduled"` beside `"gates"`:
977
+ `gates.scheduled`:
675
978
 
676
979
  ```json
677
- "ciWiring": {
678
- "gates": ["bun run lint", "bun run typecheck", "bun run test"],
980
+ "gates": {
981
+ "ci": ["bun run lint", "bun run typecheck", "bun run test"],
679
982
  "scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
680
983
  }
681
984
  ```
@@ -693,26 +996,30 @@ ci-wiring: 1 of 1 scheduled command(s) do not run on a schedule:
693
996
 
694
997
  ### Gate selection
695
998
 
696
- A repository with no TypeScript source gives `checks-lint-coverage` and
697
- `checks-test-layout` nothing to check, and test-layout still refuses its
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
698
1002
  missing `bun test` script and `bunfig.toml`. It declares the gates
699
- `checks-lint` runs as `lintGates`, beside `gates`:
1003
+ `checks-lint` runs as `gates.lint`:
700
1004
 
701
1005
  ```json
702
- "ciWiring": {
703
- "gates": ["bun run lint"],
704
- "lintGates": [
1006
+ "gates": {
1007
+ "ci": ["bun run lint"],
1008
+ "lint": [
705
1009
  "checks-commit-identity",
706
1010
  "checks-comment-gate",
707
1011
  "checks-suppressions-ratchet",
708
- "checks-ci-wiring"
1012
+ "checks-ci-wiring",
1013
+ "checks-quality"
709
1014
  ]
710
1015
  }
711
1016
  ```
712
1017
 
713
1018
  `checks-lint` runs exactly those, in the "Lint entry point" table's
714
- order, and all six when `lintGates` is absent. A step running
715
- `checks-lint` then counts only for a declared gate that `lintGates`
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`
716
1023
  keeps.
717
1024
 
718
1025
  A selection may leave out only a gate that does not apply:
@@ -725,16 +1032,21 @@ A selection may leave out only a gate that does not apply:
725
1032
  | `checks-comment-gate` | always |
726
1033
  | `checks-suppressions-ratchet` | always |
727
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 |
728
1038
 
729
- Both bins exit 2 on a `lintGates` that names an unknown gate or leaves
1039
+ Both bins exit 2 on a `gates.lint` that names an unknown gate or leaves
730
1040
  out one that always applies. ci-wiring exits 1 when the
731
1041
  selection leaves out a gate the repository's tracked files make
732
1042
  applicable, and names the gate and the files:
733
1043
 
734
1044
  ```
735
- ci-wiring: ciWiring.lintGates leaves out 2 gate(s) this repository's contents make applicable:
1045
+ ci-wiring: quality.json gates.lint leaves out 4 gate(s) this repository's contents make applicable:
736
1046
  checks-lint-coverage: the repository tracks TypeScript source (src/widget.ts)
737
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)
738
1050
  ```
739
1051
 
740
1052
  It reads the files tracked at the checkout, so the pull request that
@@ -819,9 +1131,12 @@ The shared Stryker preset's `json` reporter writes
819
1131
 
820
1132
  ## Why it is shaped this way
821
1133
 
822
- - `plugins` does not inherit through oxlint `extends`. `rules`,
823
- `categories` and `jsPlugins` do. That is why the consumer snippet
824
- restates `plugins` and nothing else.
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.
825
1140
  - `node_modules/` is excluded through the consumer's `.gitignore`, not
826
1141
  `ignorePatterns`: oxlint still walks the installed package when only
827
1142
  `ignorePatterns` names it.
@@ -834,10 +1149,32 @@ The shared Stryker preset's `json` reporter writes
834
1149
  `bun build effect-channel/index.ts --outdir dist --target node --format esm`.
835
1150
  Node refuses to type-strip a `.ts` plugin under `node_modules`, so the
836
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.
837
1162
  - `dist/` is committed. No `prepack` or `prepublishOnly` builds it, so a
838
1163
  publish ships whatever bundle the publishing worktree holds. Rebuild it
839
1164
  after pulling with `bun run build`; CI fails when the committed bundle
840
- is stale.
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.
841
1178
  - The base parses with swc because typescript 7 (tsgo) has no compiler
842
1179
  API for dependency-cruiser to use. Without `@swc/core` installed the
843
1180
  cruise silently skips every `.ts` file, so this repo's test asserts its