@avi2dg/checks 0.15.0 → 0.17.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/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +5 -2
- package/dependency-cruiser.config.js +8 -0
- package/dist/feature-rules.js +139 -71
- package/dist/index.js +426 -7
- package/docs/configs/dependency-rules.md +1 -0
- package/docs/configs/effect-rules.md +1 -0
- package/docs/configs/quality-file.md +3 -3
- package/docs/configs/typescript-rules.md +36 -0
- package/docs/design.md +10 -1
- package/docs/gates/checks-comment-gate.md +2 -0
- package/docs/gates/checks-lint.md +4 -3
- package/docs/gates/checks-quality.md +1 -1
- package/docs/gates/checks-repetition.md +72 -0
- package/docs/gates/checks-size-budget.md +57 -14
- package/oxlintrc.json +1 -0
- package/package.json +7 -1
- package/quality.schema.json +63 -18
- package/scripts/comment-matchers.ts +19 -1
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +19 -1
- package/scripts/quality-file.ts +3 -17
- package/scripts/quality.ts +2 -2
- package/scripts/repetition.ts +161 -0
- package/scripts/size-budget.ts +191 -87
- package/scripts/size-rules.ts +126 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# checks-repetition
|
|
2
|
+
|
|
3
|
+
`checks-repetition` is the gate that fails a change adding repeated lines to production code, and a reader looks it up when a production file repeats more lines than it did where the range starts.
|
|
4
|
+
|
|
5
|
+
## What it checks
|
|
6
|
+
|
|
7
|
+
It runs jscpd over the files `quality.json` declares as production:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
"sources": { "production": ["src/**/*.ts"] }
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
jscpd finds each block of at least 50 tokens and 5 lines that appears twice, within one file or across two.
|
|
14
|
+
A repeated line is a line of a file inside such a block.
|
|
15
|
+
The gate counts the repeated lines of each production file at the head and where the range starts, and fails when a file counts more at the head.
|
|
16
|
+
A renamed file is compared with its count under the old path.
|
|
17
|
+
A block counts only when both copies are in production files, so a test that copies production code changes no count.
|
|
18
|
+
Each file whose count rose is listed with the blocks it repeats and where the other copy is.
|
|
19
|
+
|
|
20
|
+
Every other file that repeats lines is listed as advisory and never fails the gate.
|
|
21
|
+
The advisory list names the production files whose count did not rise, and every other tracked `.ts` or `.tsx` file that repeats a block within itself or from another such file.
|
|
22
|
+
`.d.ts` files are not measured.
|
|
23
|
+
Nothing is committed as a baseline, because each run measures where the range starts as well as the head.
|
|
24
|
+
|
|
25
|
+
## What it reads
|
|
26
|
+
|
|
27
|
+
It reads each file from the commits at the two ends of the range rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
|
|
28
|
+
It reads `sources.production` from `quality.json`.
|
|
29
|
+
jscpd must be on `PATH`, as it is under a package script.
|
|
30
|
+
|
|
31
|
+
## Arguments
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
checks-repetition <base-ref> <head-ref>
|
|
35
|
+
checks-repetition <ref>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
With two arguments the range starts where the head branched from the base, at their merge-base.
|
|
39
|
+
With one it is that commit against its parent, or against the empty tree for a repository's first commit.
|
|
40
|
+
|
|
41
|
+
## Exit codes
|
|
42
|
+
|
|
43
|
+
| Code | When |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| 0 | no production file repeats more lines than where the range starts |
|
|
46
|
+
| 1 | a production file repeats more lines than where the range starts |
|
|
47
|
+
| 2 | `quality.json` does not decode, a ref does not resolve, or jscpd cannot run |
|
|
48
|
+
|
|
49
|
+
## Sample output
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
repetition: 1 production file(s) repeat more lines than where the range starts, at 50 tokens and 5 lines:
|
|
53
|
+
src/billing/refund.ts: 11 repeated line(s), up from 0
|
|
54
|
+
src/billing/refund.ts:1-11 repeats src/billing/legacy.ts:1-11
|
|
55
|
+
repetition: advisory, 4 file(s) repeat lines the hold does not fail:
|
|
56
|
+
src/billing/ledger.ts: 11 repeated line(s)
|
|
57
|
+
src/billing/legacy.ts: 21 repeated line(s)
|
|
58
|
+
tests/one.test.ts: 11 repeated line(s)
|
|
59
|
+
tests/two.test.ts: 11 repeated line(s)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Opting out
|
|
63
|
+
|
|
64
|
+
A repository that declares no `sources.production` passes.
|
|
65
|
+
`checks-quality` refuses a `sources.production` glob that matches no file.
|
|
66
|
+
A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
|
|
67
|
+
|
|
68
|
+
## Related topics
|
|
69
|
+
|
|
70
|
+
- [The quality file](../configs/quality-file.md)
|
|
71
|
+
- [checks-size-budget](checks-size-budget.md)
|
|
72
|
+
- [checks-lint](checks-lint.md)
|
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
# checks-size-budget
|
|
2
2
|
|
|
3
|
-
`checks-size-budget` is the gate that holds production files to the
|
|
3
|
+
`checks-size-budget` is the gate that holds production and test files to the size budget `quality.json` declares, and a reader looks it up when a file or a function runs over it.
|
|
4
4
|
|
|
5
5
|
## What it checks
|
|
6
6
|
|
|
7
|
-
It holds production files to the
|
|
7
|
+
It holds production and test files to the size budget, and lists every other file over it without failing:
|
|
8
8
|
|
|
9
9
|
```json
|
|
10
10
|
"sources": { "production": ["src/**/*.ts"] },
|
|
11
|
-
"size": { "
|
|
11
|
+
"size": { "applies": "ratchet" }
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
|
|
15
|
+
A test file keeps to the tests budget, and every other file keeps to the production budget.
|
|
16
|
+
It runs oxlint with a configuration of five rules, one for each limit, and the kit's own plugin bundle, which holds the complexity rule.
|
|
17
|
+
The kit sets each limit:
|
|
18
|
+
|
|
19
|
+
<!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
|
|
20
|
+
|
|
21
|
+
| Key | Limits | oxlint rule | Production | Tests |
|
|
22
|
+
| --- | --- | --- | --- | --- |
|
|
23
|
+
| `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
|
|
24
|
+
| `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
|
|
25
|
+
| `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
|
|
26
|
+
| `complexity` | The highest cognitive complexity a function may reach, a switch counted once | `effect-channel/cognitive-complexity` | 15 | 15 |
|
|
27
|
+
| `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
|
|
28
|
+
|
|
29
|
+
<!-- end generated size-limits -->
|
|
30
|
+
|
|
31
|
+
`size.production` and `size.tests` state only a limit that differs from the kit's:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"size": {
|
|
35
|
+
"applies": "ratchet",
|
|
36
|
+
"production": { "complexity": 12 },
|
|
37
|
+
"tests": { "fileLines": 800 }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`applies` decides which files the gate holds and what fails them:
|
|
42
|
+
|
|
43
|
+
- `ratchet` holds each production and test file the range adds or changes, a rename that edits the file included.
|
|
44
|
+
For each file and each rule, it sums how far every site runs over its limit, and fails when that sum is higher at the head than at the base of the range.
|
|
45
|
+
A file the range adds starts from zero, so it has to keep within the budget.
|
|
46
|
+
A file already over the budget passes while its overrun does not grow, and an edit that shrinks the overrun passes.
|
|
47
|
+
An edited rename is compared with the file it was renamed from.
|
|
48
|
+
Nothing is committed as a baseline, because the base of the range holds it.
|
|
49
|
+
- `all` holds every production and test file, and fails on any overrun in them.
|
|
50
|
+
|
|
51
|
+
`applies` is `ratchet` when `size` leaves it out.
|
|
17
52
|
A file the range deletes or only renames is not held.
|
|
18
|
-
Every other tracked `.ts` or `.tsx` file over the budget,
|
|
53
|
+
Every other tracked `.ts` or `.tsx` file over the budget, tooling and unchanged files alike, is listed as advisory and never fails the gate.
|
|
54
|
+
Under `ratchet` the advisory list also names each site in a held file whose overrun did not grow.
|
|
19
55
|
`.d.ts` files are not measured.
|
|
20
56
|
|
|
57
|
+
`quality.json` refuses `applies: "changed"`, which `ratchet` replaces.
|
|
58
|
+
It refuses a limit set directly under `size`, since each limit goes in `size.production` or `size.tests`.
|
|
59
|
+
|
|
21
60
|
## What it reads
|
|
22
61
|
|
|
23
62
|
It reads each file from the head commit rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
|
|
63
|
+
Under `ratchet` it also reads each held file from the base of the range, under the path the file has at the head, so a file keeps the same budget at both ends.
|
|
24
64
|
It reads `sources.production` and `size` from `quality.json`.
|
|
25
65
|
oxlint must be on `PATH`, as it is under a package script.
|
|
26
66
|
|
|
@@ -38,25 +78,28 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
38
78
|
|
|
39
79
|
| Code | When |
|
|
40
80
|
| --- | --- |
|
|
41
|
-
| 0 | every file it holds keeps within the budget |
|
|
42
|
-
| 1 | a file it holds runs over the budget |
|
|
81
|
+
| 0 | every file it holds keeps within the budget, or under `ratchet` no file's overrun grows |
|
|
82
|
+
| 1 | a file it holds runs over the budget, or under `ratchet` a file's overrun grows |
|
|
43
83
|
| 2 | `quality.json` does not decode, a ref does not resolve, or oxlint cannot run |
|
|
44
84
|
|
|
45
85
|
## Sample output
|
|
46
86
|
|
|
47
87
|
```
|
|
48
|
-
size-budget:
|
|
49
|
-
src/billing/
|
|
88
|
+
size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
|
|
89
|
+
src/billing/invoice.ts: max-lines over by 31 in total, up from 19
|
|
90
|
+
src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
|
|
91
|
+
src/billing/ledger.ts: cognitive-complexity over by 3 in total, up from 0
|
|
92
|
+
src/billing/ledger.ts:12: function `settle` has a cognitive complexity of 18. Maximum allowed is 15.
|
|
50
93
|
size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
|
|
51
|
-
tests/e2e/billing.test.ts: File has too many lines (
|
|
94
|
+
tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
|
|
52
95
|
```
|
|
53
96
|
|
|
54
97
|
## Opting out
|
|
55
98
|
|
|
56
99
|
A repository that declares no `size` passes.
|
|
57
|
-
`quality.json` refuses a `size` without `sources.production`,
|
|
100
|
+
`quality.json` refuses a `size` without `sources.production`, and `checks-quality` refuses a `sources.production` glob that matches no file.
|
|
58
101
|
A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
|
|
59
|
-
Moving `applies` from `
|
|
102
|
+
Moving `applies` from `ratchet` to `all` tightens the budget to every production and test file, once the advisory list names none.
|
|
60
103
|
|
|
61
104
|
## Related topics
|
|
62
105
|
|
package/oxlintrc.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
|
|
17
17
|
"typescript/no-unsafe-type-assertion": "error",
|
|
18
18
|
"typescript/no-non-null-assertion": "error",
|
|
19
|
+
"typescript/no-deprecated": "error",
|
|
19
20
|
"typescript/consistent-return": "off"
|
|
20
21
|
}
|
|
21
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"scripts/quality-file.ts",
|
|
42
42
|
"scripts/quality.ts",
|
|
43
43
|
"scripts/size-budget.ts",
|
|
44
|
+
"scripts/size-rules.ts",
|
|
45
|
+
"scripts/repetition.ts",
|
|
44
46
|
"scripts/feature-owners.ts",
|
|
45
47
|
"scripts/docs.ts",
|
|
46
48
|
"scripts/doc-outline.ts",
|
|
@@ -79,6 +81,7 @@
|
|
|
79
81
|
"./scripts/quality-file.ts": "./scripts/quality-file.ts",
|
|
80
82
|
"./scripts/quality.ts": "./scripts/quality.ts",
|
|
81
83
|
"./scripts/size-budget.ts": "./scripts/size-budget.ts",
|
|
84
|
+
"./scripts/repetition.ts": "./scripts/repetition.ts",
|
|
82
85
|
"./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
|
|
83
86
|
"./scripts/docs.ts": "./scripts/docs.ts",
|
|
84
87
|
"./templates/readme.md": "./templates/readme.md",
|
|
@@ -113,6 +116,7 @@
|
|
|
113
116
|
"checks-backtest": "scripts/backtest.ts",
|
|
114
117
|
"checks-quality": "scripts/quality.ts",
|
|
115
118
|
"checks-size-budget": "scripts/size-budget.ts",
|
|
119
|
+
"checks-repetition": "scripts/repetition.ts",
|
|
116
120
|
"checks-feature-owners": "scripts/feature-owners.ts",
|
|
117
121
|
"checks-docs": "scripts/docs.ts"
|
|
118
122
|
},
|
|
@@ -127,6 +131,7 @@
|
|
|
127
131
|
"dependency-cruiser": "18.4.0",
|
|
128
132
|
"@effect/tsgo": "0.45.0",
|
|
129
133
|
"effect": "4.0.0-rc.115",
|
|
134
|
+
"jscpd": "5.3.2",
|
|
130
135
|
"oxlint": "1.83.0",
|
|
131
136
|
"oxlint-tsgolint": "7.0.2002"
|
|
132
137
|
},
|
|
@@ -138,6 +143,7 @@
|
|
|
138
143
|
"ajv": "8.20.0",
|
|
139
144
|
"dependency-cruiser": "18.4.0",
|
|
140
145
|
"effect": "4.0.0-rc.115",
|
|
146
|
+
"jscpd": "5.3.2",
|
|
141
147
|
"oxlint": "1.83.0",
|
|
142
148
|
"oxlint-tsgolint": "7.0.2002",
|
|
143
149
|
"typescript": "7.0.2"
|
package/quality.schema.json
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"checks-docs",
|
|
48
48
|
"checks-quality",
|
|
49
49
|
"checks-size-budget",
|
|
50
|
+
"checks-repetition",
|
|
50
51
|
"checks-feature-owners"
|
|
51
52
|
]
|
|
52
53
|
},
|
|
@@ -148,32 +149,76 @@
|
|
|
148
149
|
"size": {
|
|
149
150
|
"type": "object",
|
|
150
151
|
"properties": {
|
|
151
|
-
"fileLines": {
|
|
152
|
-
"type": "integer",
|
|
153
|
-
"exclusiveMinimum": 0,
|
|
154
|
-
"description": "The most lines a file may hold, blank and comment lines counted"
|
|
155
|
-
},
|
|
156
|
-
"functionLines": {
|
|
157
|
-
"type": "integer",
|
|
158
|
-
"exclusiveMinimum": 0,
|
|
159
|
-
"description": "The most lines a function may span, blank and comment lines counted"
|
|
160
|
-
},
|
|
161
152
|
"applies": {
|
|
162
153
|
"type": "string",
|
|
163
154
|
"enum": [
|
|
164
|
-
"
|
|
155
|
+
"ratchet",
|
|
165
156
|
"all"
|
|
166
157
|
],
|
|
167
|
-
"description": "Which production files the budget holds:
|
|
158
|
+
"description": "Which production and test files the budget holds: ratchet, the ones a range adds or changes, to no more overrun per rule than at the range's base; all, every one. ratchet when absent. The rest are listed as advisory"
|
|
159
|
+
},
|
|
160
|
+
"production": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"fileLines": {
|
|
164
|
+
"type": "integer",
|
|
165
|
+
"exclusiveMinimum": 0,
|
|
166
|
+
"description": "The most lines a file may hold, blank and comment lines counted; 400 when absent"
|
|
167
|
+
},
|
|
168
|
+
"functionLines": {
|
|
169
|
+
"type": "integer",
|
|
170
|
+
"exclusiveMinimum": 0,
|
|
171
|
+
"description": "The most lines a function may span, blank and comment lines counted; 100 when absent"
|
|
172
|
+
},
|
|
173
|
+
"statements": {
|
|
174
|
+
"type": "integer",
|
|
175
|
+
"exclusiveMinimum": 0,
|
|
176
|
+
"description": "The most statements a function may hold; 30 when absent"
|
|
177
|
+
},
|
|
178
|
+
"complexity": {
|
|
179
|
+
"type": "integer",
|
|
180
|
+
"exclusiveMinimum": 0,
|
|
181
|
+
"description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
|
|
182
|
+
},
|
|
183
|
+
"depth": {
|
|
184
|
+
"type": "integer",
|
|
185
|
+
"exclusiveMinimum": 0,
|
|
186
|
+
"description": "The deepest a block may nest inside a function; 4 when absent"
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
"additionalProperties": false,
|
|
190
|
+
"description": "The budget of the files under sources.production, and of the files listed as advisory outside tests/"
|
|
191
|
+
},
|
|
192
|
+
"tests": {
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {
|
|
195
|
+
"fileLines": {
|
|
196
|
+
"type": "integer",
|
|
197
|
+
"exclusiveMinimum": 0,
|
|
198
|
+
"description": "The most lines a file may hold, blank and comment lines counted; 600 when absent"
|
|
199
|
+
},
|
|
200
|
+
"statements": {
|
|
201
|
+
"type": "integer",
|
|
202
|
+
"exclusiveMinimum": 0,
|
|
203
|
+
"description": "The most statements a function may hold; 50 when absent"
|
|
204
|
+
},
|
|
205
|
+
"complexity": {
|
|
206
|
+
"type": "integer",
|
|
207
|
+
"exclusiveMinimum": 0,
|
|
208
|
+
"description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
|
|
209
|
+
},
|
|
210
|
+
"depth": {
|
|
211
|
+
"type": "integer",
|
|
212
|
+
"exclusiveMinimum": 0,
|
|
213
|
+
"description": "The deepest a block may nest inside a function; 4 when absent"
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"additionalProperties": false,
|
|
217
|
+
"description": "The budget of the files under tests/, which sets no limit on a function's lines"
|
|
168
218
|
}
|
|
169
219
|
},
|
|
170
|
-
"required": [
|
|
171
|
-
"fileLines",
|
|
172
|
-
"functionLines",
|
|
173
|
-
"applies"
|
|
174
|
-
],
|
|
175
220
|
"additionalProperties": false,
|
|
176
|
-
"description": "The
|
|
221
|
+
"description": "The size budget oxlint holds production and test files to, read by checks-size-budget"
|
|
177
222
|
},
|
|
178
223
|
"features": {
|
|
179
224
|
"type": "array",
|
|
@@ -244,6 +244,24 @@ function openingBlock(found: readonly Comment[], source: string): Comment[] {
|
|
|
244
244
|
return block;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
export const REFUSED_DIRECTIVES = [
|
|
248
|
+
"@ts-expect-error",
|
|
249
|
+
"@ts-ignore",
|
|
250
|
+
"biome-ignore",
|
|
251
|
+
"eslint-disable",
|
|
252
|
+
"prettier-ignore",
|
|
253
|
+
] as const satisfies readonly string[];
|
|
254
|
+
|
|
255
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
256
|
+
|
|
257
|
+
const directiveSource = (name: string): string => {
|
|
258
|
+
const escaped = escapeRegExp(name);
|
|
259
|
+
if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
|
|
260
|
+
return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const DIRECTIVE = new RegExp(REFUSED_DIRECTIVES.map(directiveSource).join("|"));
|
|
264
|
+
|
|
247
265
|
type Check = {
|
|
248
266
|
readonly find: RegExp;
|
|
249
267
|
readonly refusal: (match: string) => string;
|
|
@@ -251,7 +269,7 @@ type Check = {
|
|
|
251
269
|
|
|
252
270
|
const CHECKS: readonly Check[] = [
|
|
253
271
|
{
|
|
254
|
-
find:
|
|
272
|
+
find: DIRECTIVE,
|
|
255
273
|
refusal: (match) =>
|
|
256
274
|
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
257
275
|
},
|
package/scripts/gates.ts
CHANGED
|
@@ -40,6 +40,7 @@ export const KIT_GATES = [
|
|
|
40
40
|
{ bin: "checks-docs", script: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
41
41
|
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
42
42
|
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
43
|
+
{ bin: "checks-repetition", script: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
43
44
|
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
44
45
|
] as const satisfies readonly KitGate[];
|
|
45
46
|
|
package/scripts/git.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Effect, Schema, Stream } from "effect";
|
|
1
|
+
import { Effect, Path, Schema, Stream } from "effect";
|
|
2
2
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
3
|
+
import { Usage } from "./main.ts";
|
|
3
4
|
|
|
4
5
|
export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
|
|
5
6
|
message: Schema.String,
|
|
@@ -159,3 +160,20 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
|
|
|
159
160
|
if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
|
|
160
161
|
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
161
162
|
});
|
|
163
|
+
|
|
164
|
+
export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
|
|
165
|
+
const [first, second, ...extra] = args;
|
|
166
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
|
|
167
|
+
return yield* rangeEnds(first, second, cwd);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// A scratch index leaves the repository's own index and working tree untouched.
|
|
171
|
+
export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
|
|
172
|
+
const path = yield* Path.Path;
|
|
173
|
+
const env = { GIT_INDEX_FILE: path.join(scratch, "index") };
|
|
174
|
+
const tree = path.join(scratch, "tree");
|
|
175
|
+
yield* git(["read-tree", rev], cwd, { env });
|
|
176
|
+
const listed = files.map((file) => `${file}\0`).join("");
|
|
177
|
+
yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}${path.sep}`], cwd, { env, input: listed });
|
|
178
|
+
return tree;
|
|
179
|
+
});
|
package/scripts/quality-file.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Console, Effect, FileSystem, JsonSchema, Path, Schema } from "effect";
|
|
2
2
|
import { LintGates, QUALITY_FILE } from "./gates.ts";
|
|
3
|
+
import { Size } from "./size-rules.ts";
|
|
3
4
|
|
|
4
5
|
const SEGMENT = String.raw`(?!\.\.?(?:/|$))(?:\*\*|(?:[\w.@+-]|\*(?!\*))+)`;
|
|
5
6
|
|
|
@@ -97,19 +98,6 @@ const Sources = Schema.Struct({
|
|
|
97
98
|
effect: Schema.optionalKey(EffectSources),
|
|
98
99
|
});
|
|
99
100
|
|
|
100
|
-
const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
|
|
101
|
-
|
|
102
|
-
const Size = Schema.Struct({
|
|
103
|
-
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
104
|
-
functionLines: LineBudget.annotate({
|
|
105
|
-
description: "The most lines a function may span, blank and comment lines counted",
|
|
106
|
-
}),
|
|
107
|
-
applies: Schema.Literals(["changed", "all"]).annotate({
|
|
108
|
-
description:
|
|
109
|
-
"Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
|
|
110
|
-
}),
|
|
111
|
-
});
|
|
112
|
-
|
|
113
101
|
const Feature = Schema.Struct({
|
|
114
102
|
name: Schema.String.check(
|
|
115
103
|
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
|
|
@@ -192,9 +180,7 @@ export const Quality = Schema.Struct({
|
|
|
192
180
|
gates: Schema.optionalKey(Gates),
|
|
193
181
|
commitIdentity: Schema.optionalKey(CommitIdentity),
|
|
194
182
|
sources: Schema.optionalKey(Sources),
|
|
195
|
-
size: Schema.optionalKey(
|
|
196
|
-
Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
|
|
197
|
-
),
|
|
183
|
+
size: Schema.optionalKey(Size),
|
|
198
184
|
features: Schema.optionalKey(
|
|
199
185
|
Features.annotate({
|
|
200
186
|
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
|
|
@@ -215,7 +201,7 @@ export const Quality = Schema.Struct({
|
|
|
215
201
|
.check(
|
|
216
202
|
Schema.makeFilter(
|
|
217
203
|
({ size, sources }) =>
|
|
218
|
-
size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds
|
|
204
|
+
size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds no production file without sources.production",
|
|
219
205
|
{
|
|
220
206
|
toJsonSchema: () => ({
|
|
221
207
|
if: { required: ["size"] },
|
package/scripts/quality.ts
CHANGED
|
@@ -120,10 +120,10 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
|
|
|
120
120
|
|
|
121
121
|
const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
|
|
122
122
|
const declared = [
|
|
123
|
-
...(quality.
|
|
123
|
+
...(quality.sources?.production ?? []).map((glob) => ({
|
|
124
124
|
glob,
|
|
125
125
|
key: "sources.production",
|
|
126
|
-
holds: "no source to
|
|
126
|
+
holds: "no source to checks-size-budget or checks-repetition",
|
|
127
127
|
})),
|
|
128
128
|
...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
|
|
129
129
|
];
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
|
+
import { readQuality } from "./quality-file.ts";
|
|
6
|
+
|
|
7
|
+
type Fragment = {
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly start: number;
|
|
10
|
+
readonly end: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Clone = readonly [Fragment, Fragment];
|
|
14
|
+
|
|
15
|
+
type Rise = {
|
|
16
|
+
readonly file: string;
|
|
17
|
+
readonly before: number;
|
|
18
|
+
readonly after: number;
|
|
19
|
+
readonly clones: readonly Clone[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Held = {
|
|
23
|
+
readonly measured: number;
|
|
24
|
+
readonly rises: readonly Rise[];
|
|
25
|
+
readonly advisory: ReadonlyMap<string, number>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class JscpdUnreadable extends Schema.TaggedError<JscpdUnreadable>()("JscpdUnreadable", {
|
|
29
|
+
message: Schema.String,
|
|
30
|
+
}) {}
|
|
31
|
+
|
|
32
|
+
const NAME = "repetition";
|
|
33
|
+
const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
|
|
34
|
+
const MIN_TOKENS = 50;
|
|
35
|
+
const MIN_LINES = 5;
|
|
36
|
+
const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
|
|
37
|
+
const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
|
|
38
|
+
const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
|
|
39
|
+
const JSCPD_FINISHED = 0;
|
|
40
|
+
const REPORT = "jscpd-report.json";
|
|
41
|
+
|
|
42
|
+
const Location = Schema.Struct({ name: Schema.String, start: Schema.Int, end: Schema.Int });
|
|
43
|
+
|
|
44
|
+
const decodeReport = Schema.decodeUnknownEffect(
|
|
45
|
+
Schema.fromJsonString(Schema.Struct({ duplicates: Schema.Array(Schema.Struct({ firstFile: Location, secondFile: Location })) })),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function fragmentOf({ name, start, end }: typeof Location.Type): Fragment {
|
|
49
|
+
return { file: name, start, end };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function repeatedLines(clones: readonly Clone[]): ReadonlyMap<string, number> {
|
|
53
|
+
const lines = new Map<string, Set<number>>();
|
|
54
|
+
for (const { file, start, end } of clones.flat()) {
|
|
55
|
+
const covered = lines.get(file) ?? new Set<number>();
|
|
56
|
+
for (let line = start; line <= end; line += 1) covered.add(line);
|
|
57
|
+
lines.set(file, covered);
|
|
58
|
+
}
|
|
59
|
+
return new Map([...lines].map(([file, covered]) => [file, covered.size]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// jscpd reads files from disk, and the working tree need not hold the revision: a merge checkout or an uncommitted edit.
|
|
63
|
+
const scan = Effect.fn("scan")(
|
|
64
|
+
function* (root: string, rev: string, files: readonly string[]) {
|
|
65
|
+
if (files.length === 0) return [];
|
|
66
|
+
const fs = yield* FileSystem.FileSystem;
|
|
67
|
+
const path = yield* Path.Path;
|
|
68
|
+
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-repetition-" });
|
|
69
|
+
const tree = yield* checkoutFiles(rev, files, scratch, root);
|
|
70
|
+
const output = path.join(scratch, "report");
|
|
71
|
+
const flags = ["--silent", "--no-tips", "--no-gitignore", "--reporters", "json", "--output", output];
|
|
72
|
+
const thresholds = ["--min-tokens", `${MIN_TOKENS}`, "--min-lines", `${MIN_LINES}`];
|
|
73
|
+
|
|
74
|
+
const run = yield* collect("jscpd", [...flags, ...thresholds, "."], tree).pipe(
|
|
75
|
+
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot run jscpd: ${cause.message}` })),
|
|
76
|
+
);
|
|
77
|
+
if (run.exitCode !== JSCPD_FINISHED) {
|
|
78
|
+
return yield* new JscpdUnreadable({ message: `jscpd exited ${run.exitCode}: ${run.stderr.trim() || run.stdout.trim()}` });
|
|
79
|
+
}
|
|
80
|
+
const { duplicates } = yield* fs.readFileString(path.join(output, REPORT)).pipe(
|
|
81
|
+
Effect.flatMap(decodeReport),
|
|
82
|
+
Effect.mapError((cause) => new JscpdUnreadable({ message: `cannot read jscpd's report: ${cause.message}` })),
|
|
83
|
+
);
|
|
84
|
+
return duplicates.map(({ firstFile, secondFile }): Clone => [fragmentOf(firstFile), fragmentOf(secondFile)]);
|
|
85
|
+
},
|
|
86
|
+
Effect.scoped,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
function clonesOf(file: string, clones: readonly Clone[]): readonly Clone[] {
|
|
90
|
+
return clones
|
|
91
|
+
.flatMap(([first, second]): Clone[] => {
|
|
92
|
+
if (first.file === file) return [[first, second]];
|
|
93
|
+
return second.file === file ? [[second, first]] : [];
|
|
94
|
+
})
|
|
95
|
+
.toSorted(([a], [b]) => a.start - b.start);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const runHold = Effect.fn("runHold")(function* (root: string, production: readonly string[], base: string, head: string) {
|
|
99
|
+
const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
|
|
100
|
+
const held = yield* pathsAt(head, pathspecs, root);
|
|
101
|
+
const holds = new Set(held);
|
|
102
|
+
const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
|
|
103
|
+
const formerPath = new Map(
|
|
104
|
+
(yield* changedPaths(base, head, pathspecs, root)).flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from]] : [])),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const before = repeatedLines(yield* scan(root, base, yield* pathsAt(base, pathspecs, root)));
|
|
108
|
+
const clones = yield* scan(root, head, held);
|
|
109
|
+
const after = repeatedLines(clones);
|
|
110
|
+
const rises = held.flatMap((file): Rise[] => {
|
|
111
|
+
const was = before.get(formerPath.get(file) ?? file) ?? 0;
|
|
112
|
+
const is = after.get(file) ?? 0;
|
|
113
|
+
return is > was ? [{ file, before: was, after: is, clones: clonesOf(file, clones) }] : [];
|
|
114
|
+
});
|
|
115
|
+
const risen = new Set(rises.map((rise) => rise.file));
|
|
116
|
+
const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...repeatedLines(yield* scan(root, head, others))]);
|
|
117
|
+
return {
|
|
118
|
+
measured: held.length,
|
|
119
|
+
rises: rises.toSorted((a, b) => a.file.localeCompare(b.file)),
|
|
120
|
+
advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))),
|
|
121
|
+
} satisfies Held;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
125
|
+
return [
|
|
126
|
+
` ${file}: ${after} repeated line(s), up from ${before}`,
|
|
127
|
+
...clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`),
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function report({ measured, rises, advisory }: Held): string {
|
|
132
|
+
const verdict =
|
|
133
|
+
rises.length === 0
|
|
134
|
+
? [`${NAME}: ${measured} production file(s) repeat no more lines than where the range starts, ${MEASURE}`]
|
|
135
|
+
: [`${NAME}: ${rises.length} production file(s) repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
|
|
136
|
+
const notice =
|
|
137
|
+
advisory.size === 0
|
|
138
|
+
? []
|
|
139
|
+
: [
|
|
140
|
+
`${NAME}: advisory, ${advisory.size} file(s) repeat lines the hold does not fail:`,
|
|
141
|
+
...[...advisory].map(([file, lines]) => ` ${file}: ${lines} repeated line(s)`),
|
|
142
|
+
];
|
|
143
|
+
return [...verdict, ...notice].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hold = Effect.gen(function* () {
|
|
147
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
148
|
+
const { base, head } = yield* rangeFromArgs(process.argv.slice(2), USAGE, root);
|
|
149
|
+
const { source, quality } = yield* readQuality(root);
|
|
150
|
+
const production = quality.sources?.production ?? [];
|
|
151
|
+
if (production.length === 0) {
|
|
152
|
+
yield* Console.log(`${NAME}: ${source} declares no sources.production`);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const held = yield* runHold(root, production, base, head);
|
|
156
|
+
|
|
157
|
+
yield* Console.log(report(held));
|
|
158
|
+
return held.rises.length === 0;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (import.meta.main) runMain(NAME, hold);
|