@avi2dg/checks 0.11.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 +203 -12
- package/dist/feature-rules.js +242 -0
- package/package.json +13 -5
- package/quality.schema.json +143 -1
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +2 -0
- package/scripts/git.ts +70 -14
- package/scripts/quality-file.ts +114 -4
- package/scripts/quality.ts +10 -2
- package/scripts/size-budget.ts +158 -0
package/README.md
CHANGED
|
@@ -8,8 +8,10 @@ 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
9
|
the `quality.json` schema with the generator that turns its Effect paths
|
|
10
10
|
into oxlint and tsconfig fragments, the shared commitlint config, the
|
|
11
|
-
shared dependency-cruiser base
|
|
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
|
|
12
13
|
comment gate with its backtest, the oxlint suppressions ratchet, the
|
|
14
|
+
size budget, the feature-owner change signal and proof check, the
|
|
13
15
|
Stryker mutation-testing preset with its no-regression comparator, the
|
|
14
16
|
CI-wiring check, and the Effect error-channel plugin compiled to
|
|
15
17
|
JavaScript.
|
|
@@ -122,6 +124,17 @@ opted into. The kit's bins find it at the git root and read it there:
|
|
|
122
124
|
"production": ["src/**/*.ts"],
|
|
123
125
|
"effect": { "paths": ["src/**/*.ts"], "exempt": ["src/host/*.ts"] }
|
|
124
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",
|
|
125
138
|
"agentRules": { "on": [], "off": [] }
|
|
126
139
|
}
|
|
127
140
|
```
|
|
@@ -134,14 +147,18 @@ opted into. The kit's bins find it at the git root and read it there:
|
|
|
134
147
|
| `gates.lint` | `checks-lint`, `checks-ci-wiring` | the gates `checks-lint` runs when not all apply; see "Gate selection" |
|
|
135
148
|
| `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit; see "Commit identity" |
|
|
136
149
|
| `sources.effect` | `checks-quality` | the paths held to the Effect rules, and the files under them that are not; see "Effect rules" |
|
|
137
|
-
| `sources.production` |
|
|
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" |
|
|
138
154
|
| `agentRules.on`, `agentRules.off` | agent Rule selection, not the kit | catalogued Rules switched on or off for this repository |
|
|
139
155
|
|
|
140
156
|
Every key is optional. The bins decode the file with one Effect
|
|
141
157
|
`Schema`, and the package ships `quality.schema.json` emitted from that
|
|
142
158
|
schema, so the `$schema` line gives an editor the verdict the bins
|
|
143
|
-
reach, save
|
|
144
|
-
|
|
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
|
|
145
162
|
not name is refused, not ignored, so a misspelt `sources` cannot switch
|
|
146
163
|
the Effect rules off unnoticed. A glob in `sources`
|
|
147
164
|
starts at the repository root, names a directory first, uses `*`
|
|
@@ -213,8 +230,9 @@ that changes a preset reaches the repository through its next
|
|
|
213
230
|
`sources.effect`;
|
|
214
231
|
- `.oxlintrc.json` or `tsconfig.json` does not list its fragment in
|
|
215
232
|
`extends`, so the tool never reads it;
|
|
216
|
-
- a `sources.effect.paths` glob
|
|
217
|
-
|
|
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.
|
|
218
236
|
|
|
219
237
|
It exits 2 when `quality.json` does not decode. `generate` writes the
|
|
220
238
|
fragments, removes a left-over one, then runs the same check.
|
|
@@ -256,6 +274,8 @@ tracked files give a gate nothing to check can leave it out through
|
|
|
256
274
|
| `checks-suppressions-ratchet` | the range |
|
|
257
275
|
| `checks-ci-wiring` | the working tree |
|
|
258
276
|
| `checks-quality` | the working tree |
|
|
277
|
+
| `checks-size-budget` | the range |
|
|
278
|
+
| `checks-feature-owners` | the range |
|
|
259
279
|
|
|
260
280
|
```sh
|
|
261
281
|
checks-lint
|
|
@@ -308,7 +328,7 @@ gate's own report, then its verdict:
|
|
|
308
328
|
```
|
|
309
329
|
checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
|
|
310
330
|
...
|
|
311
|
-
checks-lint: 3 of
|
|
331
|
+
checks-lint: 3 of 9 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
|
|
312
332
|
```
|
|
313
333
|
|
|
314
334
|
It exits 1 when any gate found a violation, and 2 when the range or the
|
|
@@ -566,7 +586,9 @@ boundary you own. A rule that restates a base name overrides it field
|
|
|
566
586
|
by field, which is how an entry point stops being an orphan:
|
|
567
587
|
redeclare `no-orphans` with your entry added to its `pathNot`.
|
|
568
588
|
This repo's own `.dependency-cruiser.cjs` does that for the plugin
|
|
569
|
-
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".
|
|
570
592
|
|
|
571
593
|
`package.json` gains the script:
|
|
572
594
|
|
|
@@ -717,6 +739,160 @@ suppressions-ratchet: 2 count(s) in oxlint-suppressions.json rose or appeared; f
|
|
|
717
739
|
|
|
718
740
|
`checks-lint` runs it over each pull request's range; see "Lint entry point".
|
|
719
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
|
+
|
|
720
896
|
## CI wiring
|
|
721
897
|
|
|
722
898
|
`checks-ci-wiring` fails when a command the repository's CI must run no
|
|
@@ -820,8 +996,9 @@ ci-wiring: 1 of 1 scheduled command(s) do not run on a schedule:
|
|
|
820
996
|
|
|
821
997
|
### Gate selection
|
|
822
998
|
|
|
823
|
-
A repository with no TypeScript source gives `checks-lint-coverage
|
|
824
|
-
`checks-test-layout`
|
|
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
|
|
825
1002
|
missing `bun test` script and `bunfig.toml`. It declares the gates
|
|
826
1003
|
`checks-lint` runs as `gates.lint`:
|
|
827
1004
|
|
|
@@ -839,7 +1016,7 @@ missing `bun test` script and `bunfig.toml`. It declares the gates
|
|
|
839
1016
|
```
|
|
840
1017
|
|
|
841
1018
|
`checks-lint` runs exactly those, in the "Lint entry point" table's
|
|
842
|
-
order, and all
|
|
1019
|
+
order, and all nine when `gates.lint` is absent. A selection in
|
|
843
1020
|
`quality.json` always keeps `checks-quality`, since the file it sits in
|
|
844
1021
|
is what makes that gate apply. A step running
|
|
845
1022
|
`checks-lint` then counts only for a declared gate that `gates.lint`
|
|
@@ -856,6 +1033,8 @@ A selection may leave out only a gate that does not apply:
|
|
|
856
1033
|
| `checks-suppressions-ratchet` | always |
|
|
857
1034
|
| `checks-ci-wiring` | always |
|
|
858
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 |
|
|
859
1038
|
|
|
860
1039
|
Both bins exit 2 on a `gates.lint` that names an unknown gate or leaves
|
|
861
1040
|
out one that always applies. ci-wiring exits 1 when the
|
|
@@ -863,9 +1042,11 @@ selection leaves out a gate the repository's tracked files make
|
|
|
863
1042
|
applicable, and names the gate and the files:
|
|
864
1043
|
|
|
865
1044
|
```
|
|
866
|
-
ci-wiring: quality.json gates.lint leaves out
|
|
1045
|
+
ci-wiring: quality.json gates.lint leaves out 4 gate(s) this repository's contents make applicable:
|
|
867
1046
|
checks-lint-coverage: the repository tracks TypeScript source (src/widget.ts)
|
|
868
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)
|
|
869
1050
|
```
|
|
870
1051
|
|
|
871
1052
|
It reads the files tracked at the checkout, so the pull request that
|
|
@@ -968,6 +1149,16 @@ The shared Stryker preset's `json` reporter writes
|
|
|
968
1149
|
`bun build effect-channel/index.ts --outdir dist --target node --format esm`.
|
|
969
1150
|
Node refuses to type-strip a `.ts` plugin under `node_modules`, so the
|
|
970
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.
|
|
971
1162
|
- `dist/` is committed. No `prepack` or `prepublishOnly` builds it, so a
|
|
972
1163
|
publish ships whatever bundle the publishing worktree holds. Rebuild it
|
|
973
1164
|
after pulling with `bun run build`; CI fails when the committed bundle
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// scripts/feature-rules.ts
|
|
2
|
+
import { Schema as Schema3 } from "effect";
|
|
3
|
+
|
|
4
|
+
// scripts/quality-file.ts
|
|
5
|
+
import { Console, Effect, FileSystem, JsonSchema, Path, Schema as Schema2 } from "effect";
|
|
6
|
+
|
|
7
|
+
// scripts/gates.ts
|
|
8
|
+
import { Schema } from "effect";
|
|
9
|
+
var EVERY_REPOSITORY = "every repository";
|
|
10
|
+
var QUALITY_FILE = "quality.json";
|
|
11
|
+
var TYPESCRIPT_SOURCE = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
12
|
+
var QUALITY_DECLARATION = { pathspecs: [QUALITY_FILE], content: `a ${QUALITY_FILE}` };
|
|
13
|
+
var KIT_GATES = [
|
|
14
|
+
{ bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
15
|
+
{ bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
16
|
+
{ bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
17
|
+
{ bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
18
|
+
{ bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
19
|
+
{ bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
20
|
+
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
21
|
+
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
22
|
+
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE }
|
|
23
|
+
];
|
|
24
|
+
var UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
|
25
|
+
var LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(Schema.makeFilter((selected) => {
|
|
26
|
+
const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
|
|
27
|
+
if (missing.length === 0)
|
|
28
|
+
return true;
|
|
29
|
+
const verb = missing.length === 1 ? "applies" : "apply";
|
|
30
|
+
return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
|
|
31
|
+
}, { toJsonSchema: () => ({ allOf: UNCONDITIONAL.map((bin) => ({ contains: { const: bin } })) }) }));
|
|
32
|
+
|
|
33
|
+
// scripts/quality-file.ts
|
|
34
|
+
var SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
|
|
35
|
+
var FILE = String.raw`(?:[\w.@+-]|\*(?!\*))*\.\w+`;
|
|
36
|
+
var PathGlob = Schema2.String.check(Schema2.isPattern(new RegExp(`^${SEGMENT}(?:/${SEGMENT})*/${FILE}$`), {
|
|
37
|
+
expected: "a glob from the repository root such as src/**/*.ts: a directory first, * within a segment, ** as a whole one, a file name with an extension last"
|
|
38
|
+
})).annotate({
|
|
39
|
+
identifier: "PathGlob",
|
|
40
|
+
description: "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./"
|
|
41
|
+
});
|
|
42
|
+
var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
43
|
+
var DirectoryPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
44
|
+
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
|
|
45
|
+
})).annotate({ identifier: "DirectoryPath" });
|
|
46
|
+
var FilePath = Schema2.String.check(Schema2.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
47
|
+
expected: "a file from the repository root such as src/billing/index.ts, with no glob"
|
|
48
|
+
})).annotate({ identifier: "FilePath" });
|
|
49
|
+
var PROOF_DIRECTORY = "tests/e2e/";
|
|
50
|
+
var ProofPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
51
|
+
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`
|
|
52
|
+
})).annotate({ identifier: "ProofPath" });
|
|
53
|
+
var Command = Schema2.NonEmptyString.annotate({ identifier: "Command" });
|
|
54
|
+
var RuleName = Schema2.String.check(Schema2.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a Rule name in kebab case" })).annotate({ identifier: "RuleName" });
|
|
55
|
+
var Identity = Schema2.Struct({ name: Schema2.NonEmptyString, email: Schema2.NonEmptyString }).annotate({
|
|
56
|
+
identifier: "Identity"
|
|
57
|
+
});
|
|
58
|
+
var CommitIdentity = Schema2.Struct({
|
|
59
|
+
authors: Schema2.NonEmptyArray(Identity).annotate({
|
|
60
|
+
description: "The identities allowed to author and commit, in place of the kit's default owner"
|
|
61
|
+
})
|
|
62
|
+
});
|
|
63
|
+
var Gates = Schema2.Struct({
|
|
64
|
+
ci: Schema2.optionalKey(Schema2.NonEmptyArray(Command).annotate({
|
|
65
|
+
description: "The commands CI runs on every pull request to the default branch, each one plain command"
|
|
66
|
+
})),
|
|
67
|
+
scheduled: Schema2.optionalKey(Schema2.Array(Command).annotate({ description: "The commands a cron-scheduled workflow runs" })),
|
|
68
|
+
lint: Schema2.optionalKey(LintGates)
|
|
69
|
+
});
|
|
70
|
+
var EffectSources = Schema2.Struct({
|
|
71
|
+
paths: Schema2.NonEmptyArray(PathGlob).annotate({
|
|
72
|
+
description: "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
|
|
73
|
+
}),
|
|
74
|
+
exempt: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "Files under paths the Effect rules pass over" }))
|
|
75
|
+
});
|
|
76
|
+
var Sources = Schema2.Struct({
|
|
77
|
+
production: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" })),
|
|
78
|
+
effect: Schema2.optionalKey(EffectSources)
|
|
79
|
+
});
|
|
80
|
+
var LineBudget = Schema2.Int.check(Schema2.isGreaterThan(0));
|
|
81
|
+
var Size = Schema2.Struct({
|
|
82
|
+
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
83
|
+
functionLines: LineBudget.annotate({
|
|
84
|
+
description: "The most lines a function may span, blank and comment lines counted"
|
|
85
|
+
}),
|
|
86
|
+
applies: Schema2.Literals(["changed", "all"]).annotate({
|
|
87
|
+
description: "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
var Feature = Schema2.Struct({
|
|
91
|
+
name: Schema2.String.check(Schema2.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" })).annotate({ description: "The owner the dependency rule and the change signal name" }),
|
|
92
|
+
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
93
|
+
entries: Schema2.NonEmptyArray(FilePath).annotate({
|
|
94
|
+
description: "The files under root that code outside it imports the feature through"
|
|
95
|
+
}),
|
|
96
|
+
allowFrom: Schema2.optionalKey(Schema2.Array(PathGlob).annotate({
|
|
97
|
+
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
|
|
98
|
+
})),
|
|
99
|
+
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" })
|
|
100
|
+
}).check(Schema2.makeFilter(({ root, entries }) => {
|
|
101
|
+
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
102
|
+
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
103
|
+
}));
|
|
104
|
+
function nests(outer, inner) {
|
|
105
|
+
return outer === inner || inner.startsWith(`${outer}/`);
|
|
106
|
+
}
|
|
107
|
+
var Features = Schema2.Array(Feature).check(Schema2.makeFilter((features) => {
|
|
108
|
+
const names = features.map((feature) => feature.name);
|
|
109
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
110
|
+
if (repeated.length > 0)
|
|
111
|
+
return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
112
|
+
for (const outer of features) {
|
|
113
|
+
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
114
|
+
if (inner !== undefined)
|
|
115
|
+
return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}));
|
|
119
|
+
var AgentRules = Schema2.Struct({
|
|
120
|
+
on: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
121
|
+
off: Schema2.optionalKey(Schema2.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" }))
|
|
122
|
+
}).check(Schema2.makeFilter(({ on = [], off = [] }) => {
|
|
123
|
+
const both = on.filter((rule) => off.includes(rule));
|
|
124
|
+
return both.length === 0 || `switches ${both.join(", ")} both on and off`;
|
|
125
|
+
}));
|
|
126
|
+
var Quality = Schema2.Struct({
|
|
127
|
+
$schema: Schema2.optionalKey(Schema2.String),
|
|
128
|
+
defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString.annotate({ description: "The branch pull requests merge into; main when absent" })),
|
|
129
|
+
gates: Schema2.optionalKey(Gates),
|
|
130
|
+
commitIdentity: Schema2.optionalKey(CommitIdentity),
|
|
131
|
+
sources: Schema2.optionalKey(Sources),
|
|
132
|
+
size: Schema2.optionalKey(Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" })),
|
|
133
|
+
features: Schema2.optionalKey(Features.annotate({
|
|
134
|
+
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to"
|
|
135
|
+
})),
|
|
136
|
+
changeSignal: Schema2.optionalKey(Schema2.Literal("advisory").annotate({
|
|
137
|
+
description: "Report which feature owners a change touches, without failing on it"
|
|
138
|
+
})),
|
|
139
|
+
agentRules: Schema2.optionalKey(AgentRules)
|
|
140
|
+
}).annotate({
|
|
141
|
+
title: QUALITY_FILE,
|
|
142
|
+
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection"
|
|
143
|
+
}).check(Schema2.makeFilter(({ size, sources }) => size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production", {
|
|
144
|
+
toJsonSchema: () => ({
|
|
145
|
+
if: { required: ["size"] },
|
|
146
|
+
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } }
|
|
147
|
+
})
|
|
148
|
+
}), Schema2.makeFilter(({ changeSignal, features = [] }) => changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features", {
|
|
149
|
+
toJsonSchema: () => ({
|
|
150
|
+
if: { required: ["changeSignal"] },
|
|
151
|
+
then: { required: ["features"], properties: { features: { minItems: 1 } } }
|
|
152
|
+
})
|
|
153
|
+
}));
|
|
154
|
+
var LegacyManifest = Schema2.Struct({
|
|
155
|
+
ciWiring: Schema2.optionalKey(Schema2.Struct({
|
|
156
|
+
gates: Schema2.optionalKey(Schema2.NonEmptyArray(Command)),
|
|
157
|
+
scheduled: Schema2.optionalKey(Schema2.Array(Command)),
|
|
158
|
+
lintGates: Schema2.optionalKey(LintGates),
|
|
159
|
+
defaultBranch: Schema2.optionalKey(Schema2.NonEmptyString)
|
|
160
|
+
})),
|
|
161
|
+
commitIdentity: Schema2.optionalKey(CommitIdentity)
|
|
162
|
+
});
|
|
163
|
+
var LEGACY_KEYS = ["ciWiring", "commitIdentity"];
|
|
164
|
+
|
|
165
|
+
class QualityUnreadable extends Schema2.TaggedError()("QualityUnreadable", {
|
|
166
|
+
message: Schema2.String
|
|
167
|
+
}) {
|
|
168
|
+
}
|
|
169
|
+
var MANIFEST = "package.json";
|
|
170
|
+
var decodeQualityJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(Quality), { onExcessProperty: "error" });
|
|
171
|
+
var decodeManifestJson = Schema2.decodeUnknownEffect(Schema2.fromJsonString(LegacyManifest));
|
|
172
|
+
var decodeQuality = (text, source) => decodeQualityJson(text).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
173
|
+
function fromLegacy({ ciWiring, commitIdentity }) {
|
|
174
|
+
const gates = {
|
|
175
|
+
...ciWiring?.gates === undefined ? {} : { ci: ciWiring.gates },
|
|
176
|
+
...ciWiring?.scheduled === undefined ? {} : { scheduled: ciWiring.scheduled },
|
|
177
|
+
...ciWiring?.lintGates === undefined ? {} : { lint: ciWiring.lintGates }
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
...ciWiring?.defaultBranch === undefined ? {} : { defaultBranch: ciWiring.defaultBranch },
|
|
181
|
+
...Object.keys(gates).length === 0 ? {} : { gates },
|
|
182
|
+
...commitIdentity === undefined ? {} : { commitIdentity }
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
var decodeManifest = (text, source) => decodeManifestJson(text).pipe(Effect.map((manifest) => ({
|
|
186
|
+
keys: LEGACY_KEYS.filter((key) => manifest[key] !== undefined),
|
|
187
|
+
quality: fromLegacy(manifest)
|
|
188
|
+
})), Effect.mapError((cause) => new QualityUnreadable({ message: `${source}: ${cause.message}` })));
|
|
189
|
+
var UNDECLARED = { keys: [], quality: {} };
|
|
190
|
+
var readQuality = Effect.fn("readQuality")(function* (root) {
|
|
191
|
+
const fs = yield* FileSystem.FileSystem;
|
|
192
|
+
const path = yield* Path.Path;
|
|
193
|
+
const read = (file) => fs.readFileString(path.join(root, file)).pipe(Effect.mapError((cause) => new QualityUnreadable({ message: `cannot read ${file}: ${cause.message}` })));
|
|
194
|
+
const legacy = (yield* fs.exists(path.join(root, MANIFEST))) ? yield* decodeManifest(yield* read(MANIFEST), MANIFEST) : UNDECLARED;
|
|
195
|
+
const keys = legacy.keys.join(" and ");
|
|
196
|
+
if (yield* fs.exists(path.join(root, QUALITY_FILE))) {
|
|
197
|
+
if (legacy.keys.length > 0) {
|
|
198
|
+
return yield* new QualityUnreadable({
|
|
199
|
+
message: `${MANIFEST} still sets ${keys}, which ${QUALITY_FILE} replaces; move what it holds there`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return { source: QUALITY_FILE, quality: yield* decodeQuality(yield* read(QUALITY_FILE), QUALITY_FILE) };
|
|
203
|
+
}
|
|
204
|
+
if (legacy.keys.length > 0) {
|
|
205
|
+
const them = legacy.keys.length === 1 ? "it" : "them";
|
|
206
|
+
yield* Console.error(`${MANIFEST} sets ${keys}, which a later minor release stops reading; move ${them} into ${QUALITY_FILE}`);
|
|
207
|
+
}
|
|
208
|
+
return { source: MANIFEST, quality: legacy.quality };
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// scripts/feature-rules.ts
|
|
212
|
+
var TESTS = "^tests/";
|
|
213
|
+
function escaped(literal) {
|
|
214
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
215
|
+
}
|
|
216
|
+
function globPattern(glob) {
|
|
217
|
+
const segments = glob.split("/");
|
|
218
|
+
const body = segments.map((segment, index) => {
|
|
219
|
+
if (segment === "**")
|
|
220
|
+
return "(?:[^/]+/)*";
|
|
221
|
+
const pattern = segment.split("*").map(escaped).join("[^/]*");
|
|
222
|
+
return index === segments.length - 1 ? pattern : `${pattern}/`;
|
|
223
|
+
});
|
|
224
|
+
return `^${body.join("")}$`;
|
|
225
|
+
}
|
|
226
|
+
function rulesFor(features) {
|
|
227
|
+
return features.map(({ name, root, entries, allowFrom = [] }) => ({
|
|
228
|
+
name: `feature-${name}-entries`,
|
|
229
|
+
severity: "error",
|
|
230
|
+
comment: `Outside ${root}/, ${name} is imported through ${entries.join(", ")}. Import one of those, or list the importer in the feature's allowFrom in quality.json.`,
|
|
231
|
+
from: { pathNot: [`^${escaped(root)}/`, TESTS, ...allowFrom.map(globPattern)] },
|
|
232
|
+
to: { path: `^${escaped(root)}/`, pathNot: entries.map((entry) => `^${escaped(entry)}$`) }
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
var decode = Schema3.decodeUnknownSync(Quality);
|
|
236
|
+
function featureRules(quality) {
|
|
237
|
+
return rulesFor(decode(quality, { onExcessProperty: "error" }).features ?? []);
|
|
238
|
+
}
|
|
239
|
+
export {
|
|
240
|
+
globPattern,
|
|
241
|
+
featureRules
|
|
242
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,13 +34,16 @@
|
|
|
34
34
|
"scripts/backtest.ts",
|
|
35
35
|
"scripts/quality-file.ts",
|
|
36
36
|
"scripts/quality.ts",
|
|
37
|
+
"scripts/size-budget.ts",
|
|
38
|
+
"scripts/feature-owners.ts",
|
|
37
39
|
"presets/effect.oxlint.json",
|
|
38
40
|
"presets/effect.language-service.json",
|
|
39
41
|
"quality.schema.json",
|
|
40
42
|
"oxlintrc.json",
|
|
41
43
|
"stryker.preset.js",
|
|
42
44
|
"tsconfig.effect.json",
|
|
43
|
-
"dist/index.js"
|
|
45
|
+
"dist/index.js",
|
|
46
|
+
"dist/feature-rules.js"
|
|
44
47
|
],
|
|
45
48
|
"exports": {
|
|
46
49
|
"./bunfig.toml": "./bunfig.toml",
|
|
@@ -61,13 +64,16 @@
|
|
|
61
64
|
"./scripts/backtest.ts": "./scripts/backtest.ts",
|
|
62
65
|
"./scripts/quality-file.ts": "./scripts/quality-file.ts",
|
|
63
66
|
"./scripts/quality.ts": "./scripts/quality.ts",
|
|
67
|
+
"./scripts/size-budget.ts": "./scripts/size-budget.ts",
|
|
68
|
+
"./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
|
|
64
69
|
"./presets/effect.oxlint.json": "./presets/effect.oxlint.json",
|
|
65
70
|
"./presets/effect.language-service.json": "./presets/effect.language-service.json",
|
|
66
71
|
"./quality.schema.json": "./quality.schema.json",
|
|
67
72
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
68
73
|
"./stryker.preset.js": "./stryker.preset.js",
|
|
69
74
|
"./tsconfig.effect.json": "./tsconfig.effect.json",
|
|
70
|
-
"./dist/index.js": "./dist/index.js"
|
|
75
|
+
"./dist/index.js": "./dist/index.js",
|
|
76
|
+
"./dist/feature-rules.js": "./dist/feature-rules.js"
|
|
71
77
|
},
|
|
72
78
|
"bin": {
|
|
73
79
|
"checks-lint": "scripts/lint.ts",
|
|
@@ -81,10 +87,12 @@
|
|
|
81
87
|
"checks-comment-gate": "scripts/comment-gate.ts",
|
|
82
88
|
"checks-suppressions-ratchet": "scripts/suppressions-ratchet.ts",
|
|
83
89
|
"checks-backtest": "scripts/backtest.ts",
|
|
84
|
-
"checks-quality": "scripts/quality.ts"
|
|
90
|
+
"checks-quality": "scripts/quality.ts",
|
|
91
|
+
"checks-size-budget": "scripts/size-budget.ts",
|
|
92
|
+
"checks-feature-owners": "scripts/feature-owners.ts"
|
|
85
93
|
},
|
|
86
94
|
"scripts": {
|
|
87
|
-
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun scripts/quality-schema.ts",
|
|
95
|
+
"build": "bun build effect-channel/index.ts --outdir dist --target node --format esm && bun build scripts/feature-rules.ts --outdir dist --target node --format esm --packages external && bun scripts/quality-schema.ts",
|
|
88
96
|
"lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
|
|
89
97
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
90
98
|
"test": "bun scripts/test.ts"
|