@avi2dg/checks 0.56.1 → 0.57.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 +8 -0
- package/docs/bins.md +6 -2
- package/docs/configs/native-settings.md +1 -1
- package/docs/design.md +2 -0
- package/docs/gates/checks-frontend-syntax.md +2 -2
- package/docs/gates/checks-lint-coverage.md +2 -1
- package/docs/gates/checks-lint.md +8 -3
- package/docs/gates/checks-night-run.md +38 -9
- package/docs/gates/checks-quarantine-clock.md +4 -2
- package/docs/gates/checks-repetition.md +12 -7
- package/docs/gates/checks-suppressions-ratchet.md +5 -2
- package/package.json +2 -1
- package/src/complexity/repetition.ts +51 -16
- package/src/complexity/suppressions-ratchet.ts +14 -5
- package/src/core/gates.ts +11 -4
- package/src/core/git.ts +3 -1
- package/src/core/lint.ts +54 -20
- package/src/testing/night-run-findings.ts +20 -3
- package/src/testing/night-run.ts +13 -7
- package/src/testing/quarantine-clock.ts +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.57.0
|
|
6
|
+
|
|
7
|
+
Released 2026-10-27.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **testing:** run live tier and four lint gates at night [#13](https://github.com/avi2d/checks/pull/13)
|
|
12
|
+
|
|
5
13
|
## 0.56.1
|
|
6
14
|
|
|
7
15
|
Released 2026-10-27.
|
package/docs/bins.md
CHANGED
|
@@ -9,7 +9,7 @@ The kit ships one bin per check, each named `checks-` and called from a package
|
|
|
9
9
|
## What runs
|
|
10
10
|
|
|
11
11
|
`checks-lint` runs each of these gates that applies to the repository, and names every one that fails.
|
|
12
|
-
A gate reads either the working tree or the range `checks-lint` resolves
|
|
12
|
+
A gate reads either the working tree or the range `checks-lint` resolves, or the whole tree at the head under `checks-lint --night`.
|
|
13
13
|
It scopes tree gates to the change unless `--full` forces the full run, and `--desk` runs the fast tree gates.
|
|
14
14
|
The table groups the gates by vector, the part of a repository each one judges.
|
|
15
15
|
|
|
@@ -37,6 +37,10 @@ The table groups the gates by vector, the part of a repository each one judges.
|
|
|
37
37
|
|
|
38
38
|
<!-- end generated gates -->
|
|
39
39
|
|
|
40
|
+
Four of these gates run only at night.
|
|
41
|
+
They are `checks-lint-coverage`, `checks-quarantine-clock`, `checks-repetition` and `checks-suppressions-ratchet`.
|
|
42
|
+
The night-run workflow runs them with `checks-lint --night`, which hands each range gate `--whole-tree HEAD`, so the four judge the whole tree with no base.
|
|
43
|
+
|
|
40
44
|
These bins run on their own:
|
|
41
45
|
|
|
42
46
|
- [`checks-test`](gates/checks-test.md) runs the suite as `scripts.test` and refuses a skip without a reason at its test site.
|
|
@@ -46,7 +50,7 @@ These bins run on their own:
|
|
|
46
50
|
- [`checks-mutation-baseline`](gates/checks-mutation-baseline.md) restores the newest mutation baseline from `main`, saves it on a self-hosted runner's disk in place of an artifact, and deletes superseded baselines after a `main` run.
|
|
47
51
|
- [`checks-mutation-scope`](gates/checks-mutation-scope.md) turns a branch's diff into the files its mutation runs mutate.
|
|
48
52
|
- [`checks-subsumed-tests`](gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
|
|
49
|
-
- [`checks-night-run`](gates/checks-night-run.md) opens or comments on one GitHub issue per finding of a night run on `main`, each file whose mutation score fell and each
|
|
53
|
+
- [`checks-night-run`](gates/checks-night-run.md) opens or comments on one GitHub issue per finding of a night run on `main`, each file whose mutation score fell, each failing test, and each failed nightly gate.
|
|
50
54
|
- [`checks-changelog`](gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
|
|
51
55
|
- [`checks-release-notes`](gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
|
|
52
56
|
- [`checks-release-report`](gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
|
|
@@ -93,7 +93,7 @@ export default defineConfig({ baseline: "widely", tokens: false });
|
|
|
93
93
|
|
|
94
94
|
The size rules are oxlint's own rules at `error`, which every `oxlint.config.ts` built with `defineConfig` sets over the whole tree, so `bun run lint` fails on any file over them.
|
|
95
95
|
A repository records its existing violations with `oxlint --suppress-all`, which writes them to `oxlint-suppressions.json`.
|
|
96
|
-
`checks-suppressions-ratchet` refuses
|
|
96
|
+
`checks-suppressions-ratchet` refuses every count that file still holds when the night run scans the whole tree.
|
|
97
97
|
`SOURCE_LIMITS` holds the limits for sources and `TEST_LIMITS` the limits for tests:
|
|
98
98
|
|
|
99
99
|
<!-- generated size-limits: bun run build writes it from SOURCE_LIMITS, TEST_LIMITS, SOURCES and TESTS in src/quality/presets/oxlint.ts and scripts/doc-blocks.ts -->
|
package/docs/design.md
CHANGED
|
@@ -126,6 +126,7 @@ The gates run one at a time and pass their output straight through, so each repo
|
|
|
126
126
|
`checks-lint` picks the gates that apply from the tracked files for the full run, and a repository cannot select gates.
|
|
127
127
|
A scoped run selects a subset by changed paths, and the desk run selects the fast tree gates.
|
|
128
128
|
A TypeScript gate applies as soon as the repository tracks TypeScript source, and `checks-lint-coverage` and `checks-graphwalk` also apply on tracked Astro source.
|
|
129
|
+
`checks-lint-coverage` and `checks-repetition` run only at night.
|
|
129
130
|
|
|
130
131
|
dependency-cruiser runs inside the `checks-graphwalk` gate rather than as a script each repository adds to `lint`, so the full run keeps it.
|
|
131
132
|
A repository with no config of its own is cruised against the kit's defaults, since every rule has one.
|
|
@@ -162,6 +163,7 @@ A stylesheet already over 400 lines fails until it is split, whatever the suppre
|
|
|
162
163
|
`checks-repetition` runs jscpd at both ends of the range with the head's `.jscpd.json`, so the same `path` and `ignore` globs pick the files at both ends.
|
|
163
164
|
It compares each file's count of repeated lines, and does not use jscpd's `--baseline-from-ref`.
|
|
164
165
|
That flag reports a repeated block as new once its text changes, so a change that shortens an existing repeated block would fail.
|
|
166
|
+
At night `checks-lint --night` runs both ratchets over the whole tree with no base, so every suppression count and every repeated line still present fails until it is removed.
|
|
165
167
|
|
|
166
168
|
## checks-test runs the suite itself
|
|
167
169
|
|
|
@@ -54,14 +54,14 @@ The builder changes four of the shared config's rules:
|
|
|
54
54
|
- In an `.html` or `.astro` file, `declaration-block-single-line-max-declarations` is off, since a `style` attribute is one line.
|
|
55
55
|
|
|
56
56
|
The comment rule refuses every comment but a sourcemap `/*# */` one, which stylelint passes to keep the map.
|
|
57
|
-
CSS repetition is not a family of this gate: a repository adds `"css"` to `format` in `.jscpd.json`, and [checks-repetition](checks-repetition.md) then fails
|
|
57
|
+
CSS repetition is not a family of this gate: a repository adds `"css"` to `format` in `.jscpd.json`, and [checks-repetition](checks-repetition.md) then fails at night on every repeated line a stylesheet still holds.
|
|
58
58
|
A stylesheet's length is not a family of this gate either, and [checks-css-size](checks-css-size.md) fails one that holds more than 400 lines.
|
|
59
59
|
|
|
60
60
|
### What no comment turns off
|
|
61
61
|
|
|
62
62
|
It sets stylelint's `ignoreDisables`, so a `stylelint-disable` comment silences nothing in either run, and [checks-comment-gate](checks-comment-gate.md) refuses one on the line that adds it.
|
|
63
63
|
A repository adopts a family over existing problems by counting them in `stylelint-suppressions.json` with `--suppress`.
|
|
64
|
-
stylelint lets a file and rule through while its problems stay within the count, and [checks-suppressions-ratchet](checks-suppressions-ratchet.md) fails
|
|
64
|
+
stylelint lets a file and rule through while its problems stay within the count, and the nightly [checks-suppressions-ratchet](checks-suppressions-ratchet.md) fails on every count still present until it is removed.
|
|
65
65
|
`--suppress` counts only what the repository run reports, and a file that does not parse still fails in the kit run.
|
|
66
66
|
|
|
67
67
|
### Files
|
|
@@ -53,7 +53,8 @@ lint-coverage: the program tsconfig.json builds holds the ts-reset rules is-arra
|
|
|
53
53
|
|
|
54
54
|
## When it runs
|
|
55
55
|
|
|
56
|
-
`checks-lint` runs it
|
|
56
|
+
`checks-lint --night` runs it nightly when the repository tracks a `.ts`, `.tsx` or `.astro` file.
|
|
57
|
+
No pull request run selects it.
|
|
57
58
|
|
|
58
59
|
## Related topics
|
|
59
60
|
|
|
@@ -4,11 +4,12 @@ audience: consumers
|
|
|
4
4
|
---
|
|
5
5
|
# checks-lint
|
|
6
6
|
|
|
7
|
-
`checks-lint` runs every applicable
|
|
7
|
+
`checks-lint` runs every applicable gate in the selected set and reports each failure.
|
|
8
8
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
11
11
|
It runs the gates under [What runs](../bins.md#what-runs), each in its own process.
|
|
12
|
+
With `--night` it runs the four nightly gates instead.
|
|
12
13
|
It scopes the run to the gates the change reaches, and the full run stays the fallback.
|
|
13
14
|
Applicability below selects the full run, and a scoped or desk run selects a subset.
|
|
14
15
|
A Markdown change runs the range gates plus `checks-copy`.
|
|
@@ -31,6 +32,7 @@ A local run with `GITHUB_BASE_REF` set starts at that branch on `origin` instead
|
|
|
31
32
|
A clone with no remote tracking refs checks `HEAD` alone.
|
|
32
33
|
On a pull request the range ends at the event's head commit and starts at its merge base with the event's base branch.
|
|
33
34
|
Tree gates read the working tree rather than the range.
|
|
35
|
+
With `--night` the range gates read the whole tree at `HEAD` instead of a range.
|
|
34
36
|
|
|
35
37
|
## Arguments
|
|
36
38
|
|
|
@@ -38,11 +40,13 @@ Tree gates read the working tree rather than the range.
|
|
|
38
40
|
checks-lint
|
|
39
41
|
checks-lint [--full | --desk]
|
|
40
42
|
checks-lint [--full | --desk] <base-ref> <head-ref>
|
|
43
|
+
checks-lint --night
|
|
41
44
|
```
|
|
42
45
|
|
|
43
46
|
With two arguments, the base and head override range discovery.
|
|
44
47
|
`--full` forces the unscoped run.
|
|
45
48
|
`--desk` runs the fast tree gates.
|
|
49
|
+
With `--night`, it takes no other arguments and runs the four nightly gates over the whole tree.
|
|
46
50
|
|
|
47
51
|
## Exit codes
|
|
48
52
|
|
|
@@ -54,11 +58,11 @@ With two arguments, the base and head override range discovery.
|
|
|
54
58
|
|
|
55
59
|
## Sample output
|
|
56
60
|
|
|
57
|
-
<!-- generated lint-sample: bun run build writes it from
|
|
61
|
+
<!-- generated lint-sample: bun run build writes it from PR_GATES in src/core/gates.ts and scripts/doc-blocks.ts -->
|
|
58
62
|
|
|
59
63
|
```
|
|
60
64
|
checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
|
|
61
|
-
checks-lint: 1 of
|
|
65
|
+
checks-lint: 1 of 13 gate(s) failed: checks-comment-gate
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
<!-- end generated lint-sample -->
|
|
@@ -69,6 +73,7 @@ A repository runs it from `bun run lint` in a pull request workflow that fetches
|
|
|
69
73
|
It leaves out the TypeScript gates while the repository tracks no TypeScript file, except that `checks-lint-coverage` and `checks-graphwalk` run when it tracks an `.astro` file.
|
|
70
74
|
It never runs `checks-browser`, which a product runs after it builds its site.
|
|
71
75
|
A pull request run scopes the gates to the change.
|
|
76
|
+
A repository runs the four nightly gates from its night-run workflow with `checks-lint --night` over the whole tree.
|
|
72
77
|
|
|
73
78
|
## Related topics
|
|
74
79
|
|
|
@@ -4,7 +4,7 @@ audience: consumers
|
|
|
4
4
|
---
|
|
5
5
|
# checks-night-run
|
|
6
6
|
|
|
7
|
-
`checks-night-run` turns a night run on `main` into GitHub issues, one for each file whose mutation score fell and one for each
|
|
7
|
+
`checks-night-run` turns a night run on `main` into GitHub issues, one for each file whose mutation score fell, one for each failing test, and one for each failed nightly gate.
|
|
8
8
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
@@ -15,6 +15,9 @@ A file only one night holds is no finding, since it lacks one of the two scores.
|
|
|
15
15
|
A file with no scored mutant has no score, so it is no finding either.
|
|
16
16
|
An equal score is no finding, even when it counts other mutants.
|
|
17
17
|
Each failing test in a JUnit report is a finding, whatever the previous night holds.
|
|
18
|
+
Each nightly gate the lint log names as failed is a finding.
|
|
19
|
+
A log where every named gate is undecided files `checks-lint` itself, with no per-gate issue.
|
|
20
|
+
A log with no passing run and no named gate files `checks-lint` itself, so a gate run that never finished still reaches an issue.
|
|
18
21
|
A file whose test worker crashed is one finding, since Bun reports none of its tests.
|
|
19
22
|
With no previous night it compares no score and still files each failing test.
|
|
20
23
|
With no mutation report it compares and records no score and still files each failing test.
|
|
@@ -26,6 +29,7 @@ Each finding has a key that stays the same from night to night:
|
|
|
26
29
|
| a file whose mutation score fell | `mutation:<file>` |
|
|
27
30
|
| a failing test | `test:<file> > <describe> > <name>` |
|
|
28
31
|
| a file whose test worker crashed | `test:<file>` |
|
|
32
|
+
| a nightly gate that failed | `gates:<gate>` |
|
|
29
33
|
|
|
30
34
|
## How it files a finding
|
|
31
35
|
|
|
@@ -33,8 +37,8 @@ It creates the `night-run` label when the repository lacks it.
|
|
|
33
37
|
It reads every open issue labelled `night-run`, and reads the key each one carries in a hidden marker in its body.
|
|
34
38
|
A finding whose key an open issue carries gets one comment on that issue, so a regression that lasts two nights stays one issue.
|
|
35
39
|
Any other finding opens one issue labelled `night-run`.
|
|
36
|
-
The issue and the comment name tonight's run, the file and
|
|
37
|
-
The body holds the file, the full test name and the finding key.
|
|
40
|
+
The issue and the comment name tonight's run, the file and the test, the score of both nights or the failed gate.
|
|
41
|
+
The body holds the file, the full test name or the failed gate, and the finding key.
|
|
38
42
|
A title longer than GitHub's 256 characters is cut to at most 256 and ends in `…`.
|
|
39
43
|
A night with no finding makes no call to GitHub's issues or labels at all.
|
|
40
44
|
Closing an issue ends it, and the same finding on a later night opens a new one.
|
|
@@ -44,6 +48,7 @@ A run on any ref but `refs/heads/main` files no issue and records no score, and
|
|
|
44
48
|
## What it reads
|
|
45
49
|
|
|
46
50
|
It reads tonight's Stryker `mutation.json` report, when the mutation run left one, and any number of JUnit reports, such as the ones `checks-test --junit=<file>` keeps, as [checks-test](checks-test.md#arguments) says.
|
|
51
|
+
It reads the lint log `--gates` names for the `gate(s) failed:` line `checks-lint --night` prints.
|
|
47
52
|
It scans the newest 30 successful runs of the same workflow on `main` from newest to oldest.
|
|
48
53
|
It takes the first run whose `night-run` artifact still exists as the previous night.
|
|
49
54
|
That artifact holds `night-run.json`, the scores the run recorded.
|
|
@@ -56,11 +61,12 @@ It calls `gh` with the job's `GH_TOKEN`, which needs `actions: read` for the pre
|
|
|
56
61
|
## Arguments
|
|
57
62
|
|
|
58
63
|
```sh
|
|
59
|
-
checks-night-run [--mutation <mutation-report>] [--tests <junit-report>]...
|
|
64
|
+
checks-night-run [--mutation <mutation-report>] [--tests <junit-report>]... [--gates <lint-log>]
|
|
60
65
|
```
|
|
61
66
|
|
|
62
67
|
`--mutation` names tonight's Stryker report and is given at most once.
|
|
63
68
|
`--tests` names one JUnit report and is given once per report.
|
|
69
|
+
`--gates` names tonight's `checks-lint --night` log and is given at most once.
|
|
64
70
|
At least one report is given.
|
|
65
71
|
|
|
66
72
|
## Exit codes
|
|
@@ -68,7 +74,7 @@ At least one report is given.
|
|
|
68
74
|
| Code | When |
|
|
69
75
|
| --- | --- |
|
|
70
76
|
| 0 | it filed every finding and recorded tonight's scores, it found nothing to file, or the run is off `main` and it listed each finding |
|
|
71
|
-
| 2 | an argument does not parse, a report does not decode, a GitHub Actions variable is unset or a `gh` call fails |
|
|
77
|
+
| 2 | an argument does not parse, a report or the lint log does not decode, a GitHub Actions variable is unset or a `gh` call fails |
|
|
72
78
|
|
|
73
79
|
It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
74
80
|
|
|
@@ -83,7 +89,7 @@ checks-night-run: recorded 42 file score(s) in reports/night-run.json
|
|
|
83
89
|
|
|
84
90
|
## When it runs
|
|
85
91
|
|
|
86
|
-
Only a night workflow the repository writes runs it, after that workflow's full mutation run
|
|
92
|
+
Only a night workflow the repository writes runs it, after that workflow's full mutation run, its long test tiers and its nightly lint gates.
|
|
87
93
|
|
|
88
94
|
## Running it at night
|
|
89
95
|
|
|
@@ -132,8 +138,24 @@ jobs:
|
|
|
132
138
|
name: night-run-tests
|
|
133
139
|
path: reports/night-run-tests/
|
|
134
140
|
retention-days: 1
|
|
141
|
+
gates:
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v5
|
|
145
|
+
with:
|
|
146
|
+
fetch-depth: 0
|
|
147
|
+
- uses: oven-sh/setup-bun@v2
|
|
148
|
+
with:
|
|
149
|
+
bun-version-file: .bun-version
|
|
150
|
+
- run: bun install --frozen-lockfile
|
|
151
|
+
- run: mkdir -p reports/night-run-gates && bunx checks-lint --night > reports/night-run-gates/gates.log 2>&1 || true
|
|
152
|
+
- uses: actions/upload-artifact@v4
|
|
153
|
+
with:
|
|
154
|
+
name: night-run-gates
|
|
155
|
+
path: reports/night-run-gates/
|
|
156
|
+
retention-days: 1
|
|
135
157
|
report:
|
|
136
|
-
needs: [mutation, tests]
|
|
158
|
+
needs: [mutation, gates, tests]
|
|
137
159
|
if: ${{ !cancelled() && needs.tests.result == 'success' }}
|
|
138
160
|
runs-on: ubuntu-latest
|
|
139
161
|
permissions:
|
|
@@ -151,13 +173,18 @@ jobs:
|
|
|
151
173
|
with:
|
|
152
174
|
name: night-run-mutation
|
|
153
175
|
path: reports/mutation
|
|
176
|
+
- if: needs.gates.result == 'success'
|
|
177
|
+
uses: actions/download-artifact@v4
|
|
178
|
+
with:
|
|
179
|
+
name: night-run-gates
|
|
180
|
+
path: reports/night-run-gates
|
|
154
181
|
- uses: actions/download-artifact@v4
|
|
155
182
|
with:
|
|
156
183
|
name: night-run-tests
|
|
157
184
|
path: reports/night-run-tests
|
|
158
185
|
- env:
|
|
159
186
|
GH_TOKEN: ${{ github.token }}
|
|
160
|
-
run: bunx checks-night-run ${{ needs.mutation.result == 'success' && '--mutation reports/mutation/mutation.json' || '' }} --tests reports/night-run-tests/live.xml --tests reports/night-run-tests/pixel.xml
|
|
187
|
+
run: bunx checks-night-run ${{ needs.mutation.result == 'success' && '--mutation reports/mutation/mutation.json' || '' }} --tests reports/night-run-tests/live.xml --tests reports/night-run-tests/pixel.xml ${{ needs.gates.result == 'success' && '--gates reports/night-run-gates/gates.log' || '' }}
|
|
161
188
|
- uses: actions/upload-artifact@v4
|
|
162
189
|
with:
|
|
163
190
|
name: night-run
|
|
@@ -171,14 +198,16 @@ A repository takes minute 7 of its own tier's 30 minute night window, so reposit
|
|
|
171
198
|
The tests job runs each long tier the repository holds, `live` and `pixel` as [checks-test](checks-test.md#test-tiers) names them, and keeps each JUnit report.
|
|
172
199
|
Each `|| true` lets the job go on to the upload after a failing test, since that test becomes an issue.
|
|
173
200
|
A tier that writes no report fails the report job, because `checks-night-run` cannot read it.
|
|
201
|
+
The gates job runs the nightly lint gates and keeps the log, since a failing gate becomes an issue.
|
|
174
202
|
The report job runs whenever the tests job succeeds, so a failing test that also stops Stryker still becomes an issue.
|
|
175
203
|
It passes `--mutation` only when the mutation job succeeded, and the failed mutation job still fails the run.
|
|
204
|
+
It passes `--gates` only when the gates job succeeded, and the failed gates job still fails the run.
|
|
176
205
|
The concurrency group queues a run started by hand behind the scheduled one, so two runs never file the same finding twice.
|
|
177
206
|
A dispatch on a branch runs both jobs and lists its findings in the report job's log, and `if-no-files-found: ignore` lets that run upload no record.
|
|
178
207
|
`issues: write` on the report job is the one permission beyond reading the repository and its runs.
|
|
179
208
|
A public repository keeps `runs-on: ubuntu-latest` on every job.
|
|
180
209
|
A private repository sets `runs-on` on the mutation job to the labels declared under `checks.mutationRunner` in `package.json`, and [checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names `CI_RUNS_ON`.
|
|
181
|
-
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the tests and report jobs.
|
|
210
|
+
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the tests, gates, and report jobs.
|
|
182
211
|
A successful run uploads `night-run`, so the next night compares with it, and a failed run uploads nothing a later night reads.
|
|
183
212
|
The artifact keeps 90 days, so a gap of nights still leaves a previous night to compare with.
|
|
184
213
|
|
|
@@ -25,10 +25,12 @@ A rebase keeps the entry's author date and moves the head's committer date forwa
|
|
|
25
25
|
```sh
|
|
26
26
|
checks-quarantine-clock <base-ref> <head-ref>
|
|
27
27
|
checks-quarantine-clock <ref>
|
|
28
|
+
checks-quarantine-clock --whole-tree <head-ref>
|
|
28
29
|
```
|
|
29
30
|
|
|
30
|
-
With two arguments it judges the head, and the base
|
|
31
|
+
With two arguments it judges the head, and the base must resolve but changes nothing.
|
|
31
32
|
With one it judges that commit, including a repository's first commit.
|
|
33
|
+
With `--whole-tree` it judges the named head, which is the form `checks-lint --night` runs.
|
|
32
34
|
|
|
33
35
|
## Exit codes
|
|
34
36
|
|
|
@@ -47,7 +49,7 @@ quarantine-clock: 1 test(s) in tests/quarantine/ is past 30 days; fix each and m
|
|
|
47
49
|
|
|
48
50
|
## When it runs
|
|
49
51
|
|
|
50
|
-
`checks-lint` runs it
|
|
52
|
+
`checks-lint --night` runs it nightly in every repository, as [checks-lint](checks-lint.md) says.
|
|
51
53
|
A repository with no test file under `tests/quarantine/` passes with nothing checked.
|
|
52
54
|
|
|
53
55
|
## Related topics
|
|
@@ -12,11 +12,12 @@ The gate runs jscpd at 50 tokens and 5 lines against the base and head revisions
|
|
|
12
12
|
It compares repeated lines for each file, so a decrease in another file never offsets a rise.
|
|
13
13
|
It follows an edited rename back to the original file.
|
|
14
14
|
A file that repeats lines without a rise is advisory.
|
|
15
|
+
At night `checks-lint --night` scans the head alone with no base, so every file that repeats lines at the head fails until the repetition is removed, and no file is advisory.
|
|
15
16
|
A block two bins need goes into a module both import, as `rangeFromArgs` and `checkoutFiles` in `src/core/git.ts` show.
|
|
16
17
|
|
|
17
18
|
## What it reads
|
|
18
19
|
|
|
19
|
-
jscpd reads the head's `.jscpd.json` at
|
|
20
|
+
jscpd reads the head's `.jscpd.json` at each end it scans, so its `path` and `ignore` globs select the files to measure.
|
|
20
21
|
A `path` entry the base revision lacks reads as empty there, so a newly measured folder never fails the scan.
|
|
21
22
|
|
|
22
23
|
```json
|
|
@@ -27,33 +28,37 @@ A repository that lists `"css"` in `format` measures its stylesheets the same wa
|
|
|
27
28
|
|
|
28
29
|
The gate sets the 50 tokens and 5 lines itself, over any the file names.
|
|
29
30
|
A `threshold` the file names never fails the gate, which reads jscpd's report once the scan finishes.
|
|
30
|
-
The bin reads the
|
|
31
|
+
The bin reads the named commits rather than the working tree's file contents.
|
|
31
32
|
|
|
32
33
|
## Arguments
|
|
33
34
|
|
|
34
35
|
```sh
|
|
35
36
|
checks-repetition <base-ref> <head-ref>
|
|
36
37
|
checks-repetition <ref>
|
|
38
|
+
checks-repetition --whole-tree <head-ref>
|
|
37
39
|
```
|
|
38
40
|
|
|
41
|
+
With `--whole-tree` it scans the named head alone, which is the form `checks-lint --night` runs.
|
|
42
|
+
|
|
39
43
|
## Exit codes
|
|
40
44
|
|
|
41
45
|
| Code | Result |
|
|
42
46
|
| --- | --- |
|
|
43
|
-
| 0 |
|
|
44
|
-
| 1 |
|
|
47
|
+
| 0 | Across a range, no measured file repeated more lines. With `--whole-tree`, no measured file repeats lines at the head. |
|
|
48
|
+
| 1 | Across a range, at least one measured file repeated more lines. With `--whole-tree`, at least one measured file repeats lines at the head. |
|
|
45
49
|
| 2 | A config or ref is invalid, or jscpd cannot run. |
|
|
46
50
|
|
|
47
51
|
## Sample output
|
|
48
52
|
|
|
49
53
|
```
|
|
50
|
-
repetition: 1 file(s) .jscpd.json holds repeat
|
|
51
|
-
src/copy.ts: 10 repeated line(s)
|
|
54
|
+
repetition: 1 file(s) .jscpd.json holds repeat lines at the head, at 50 tokens and 5 lines:
|
|
55
|
+
src/copy.ts: 10 repeated line(s)
|
|
56
|
+
src/copy.ts:1-10 repeats src/ledger.ts:1-10
|
|
52
57
|
```
|
|
53
58
|
|
|
54
59
|
## When it runs
|
|
55
60
|
|
|
56
|
-
`checks-lint` runs it
|
|
61
|
+
`checks-lint --night` runs it nightly when the repository tracks a `.ts` or `.tsx` file.
|
|
57
62
|
When the head holds no `.jscpd.json`, the gate reports that no file was measured.
|
|
58
63
|
|
|
59
64
|
## Related topics
|
|
@@ -11,6 +11,7 @@ audience: consumers
|
|
|
11
11
|
oxlint accepts whatever `oxlint --suppress-all` writes to `oxlint-suppressions.json`, so raising a count to let a new site through passes the lint.
|
|
12
12
|
stylelint accepts `stylelint-suppressions.json` the same way, and writes it in the same shape, a count per rule per file.
|
|
13
13
|
The ratchet fails naming the suppressions file, the file and the rule of each count that rose or appeared.
|
|
14
|
+
At night `checks-lint --night` compares the head against the empty tree, so every count still present is reported until it is removed.
|
|
14
15
|
A count that fell and an entry that went both pass.
|
|
15
16
|
|
|
16
17
|
## What it reads
|
|
@@ -23,10 +24,12 @@ A commit without a file counts it as empty, so the commit that first adds a base
|
|
|
23
24
|
```sh
|
|
24
25
|
checks-suppressions-ratchet <base-ref> <head-ref>
|
|
25
26
|
checks-suppressions-ratchet <ref>
|
|
27
|
+
checks-suppressions-ratchet --whole-tree <head-ref>
|
|
26
28
|
```
|
|
27
29
|
|
|
28
30
|
With two arguments it reads the base where the head branched off, at their merge-base, so a count the base branch lowered since does not read as a rise at the head.
|
|
29
31
|
With one it compares that commit with its parent, or with the empty tree for a repository's first commit.
|
|
32
|
+
With `--whole-tree` it compares the named head with the empty tree, which is the form `checks-lint --night` runs.
|
|
30
33
|
|
|
31
34
|
## Exit codes
|
|
32
35
|
|
|
@@ -46,8 +49,8 @@ suppressions-ratchet: 2 count(s) rose or appeared; fix the site instead of suppr
|
|
|
46
49
|
|
|
47
50
|
## When it runs
|
|
48
51
|
|
|
49
|
-
`checks-lint` runs it
|
|
50
|
-
A repository with neither file passes
|
|
52
|
+
`checks-lint --night` runs it nightly in every repository, as [checks-lint](checks-lint.md) says.
|
|
53
|
+
A repository with neither file passes.
|
|
51
54
|
|
|
52
55
|
## Related topics
|
|
53
56
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "avi2d",
|
|
@@ -210,6 +210,7 @@
|
|
|
210
210
|
"test:fast": "bun scripts/test-fast.ts",
|
|
211
211
|
"test:unit": "bun src/testing/test.ts --tier=unit",
|
|
212
212
|
"test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
|
|
213
|
+
"test:live": "bun src/testing/test.ts --tier=live",
|
|
213
214
|
"mutate": "bunx stryker run",
|
|
214
215
|
"mutate:incremental": "bunx stryker run --incremental"
|
|
215
216
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
-
import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } from "../core/git.ts";
|
|
4
|
-
import {
|
|
3
|
+
import { changedPaths, checkoutFiles, collect, commitOf, git, pathsAt, rangeFromArgs } from "../core/git.ts";
|
|
4
|
+
import { WHOLE_TREE } from "../core/gates.ts";
|
|
5
|
+
import { Usage, runMain } from "../core/main.ts";
|
|
5
6
|
|
|
6
7
|
export type Fragment = {
|
|
7
8
|
readonly file: string;
|
|
@@ -29,7 +30,7 @@ class JscpdUnreadable extends Schema.TaggedError<JscpdUnreadable>()("JscpdUnread
|
|
|
29
30
|
}) {}
|
|
30
31
|
|
|
31
32
|
const NAME = "repetition";
|
|
32
|
-
const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref>";
|
|
33
|
+
const USAGE = "usage: repetition.ts <ref> | <base-ref> <head-ref> | --whole-tree <head-ref>";
|
|
33
34
|
const MIN_TOKENS = 50;
|
|
34
35
|
const MIN_LINES = 5;
|
|
35
36
|
const MEASURE = `at ${MIN_TOKENS} tokens and ${MIN_LINES} lines`;
|
|
@@ -164,31 +165,65 @@ const runHold = Effect.fn("runHold")(function* (root: string, config: string, ba
|
|
|
164
165
|
return heldOf(sources, rises, after) satisfies Held;
|
|
165
166
|
});
|
|
166
167
|
|
|
168
|
+
const runWholeTree = Effect.fn("runWholeTree")(function* (root: string, config: string, head: string) {
|
|
169
|
+
const { sources, clones } = yield* scan(root, head, config, []);
|
|
170
|
+
const after = repeatedLines(clones);
|
|
171
|
+
const rises = risesOf([...after.keys()], new Map(), after, new Map(), clones);
|
|
172
|
+
return heldOf(sources, rises, after) satisfies Held;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
function cloneLines(clones: readonly Clone[]): readonly string[] {
|
|
176
|
+
return clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
export function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
168
|
-
return [
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
return [` ${file}: ${after} repeated line(s), up from ${before}`, ...cloneLines(clones)];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function wholeTreeRise({ file, after, clones }: Rise): readonly string[] {
|
|
184
|
+
return [` ${file}: ${after} repeated line(s)`, ...cloneLines(clones)];
|
|
172
185
|
}
|
|
173
186
|
|
|
187
|
+
const noticeOf = (advisory: Held["advisory"]): readonly string[] =>
|
|
188
|
+
advisory.size === 0
|
|
189
|
+
? []
|
|
190
|
+
: [
|
|
191
|
+
`${NAME}: advisory, ${advisory.size} file(s) repeat lines the hold does not fail:`,
|
|
192
|
+
...[...advisory].map(([file, lines]) => ` ${file}: ${lines} repeated line(s)`),
|
|
193
|
+
];
|
|
194
|
+
|
|
174
195
|
export function report({ measured, rises, advisory }: Held): string {
|
|
175
196
|
const verdict =
|
|
176
197
|
rises.length === 0
|
|
177
198
|
? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no more lines than where the range starts, ${MEASURE}`]
|
|
178
199
|
: [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat more lines than where the range starts, ${MEASURE}:`, ...rises.flatMap(describe)];
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
200
|
+
return [...verdict, ...noticeOf(advisory)].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function reportWholeTree({ measured, rises }: Held): string {
|
|
204
|
+
const verdict =
|
|
205
|
+
rises.length === 0
|
|
206
|
+
? [`${NAME}: ${measured} file(s) ${CONFIG} holds repeat no lines at the head, ${MEASURE}`]
|
|
207
|
+
: [`${NAME}: ${rises.length} file(s) ${CONFIG} holds repeat lines at the head, ${MEASURE}:`, ...rises.flatMap(wholeTreeRise)];
|
|
208
|
+
return verdict.join("\n");
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
const hold = Effect.gen(function* () {
|
|
190
212
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
191
|
-
const
|
|
213
|
+
const args = process.argv.slice(2);
|
|
214
|
+
if (args[0] === WHOLE_TREE) {
|
|
215
|
+
const headArg = args[1];
|
|
216
|
+
if (args.length !== 2 || headArg === undefined) return yield* new Usage({ message: USAGE });
|
|
217
|
+
const wholeHead = yield* commitOf(headArg);
|
|
218
|
+
if ((yield* pathsAt(wholeHead, [`:(literal)${CONFIG}`], root)).length === 0) {
|
|
219
|
+
yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
const whole = yield* runWholeTree(root, yield* git(["show", `${wholeHead}:${CONFIG}`], root), wholeHead);
|
|
223
|
+
yield* Console.log(reportWholeTree(whole));
|
|
224
|
+
return whole.rises.length === 0;
|
|
225
|
+
}
|
|
226
|
+
const { base, head } = yield* rangeFromArgs(args, USAGE, root);
|
|
192
227
|
if ((yield* pathsAt(head, [`:(literal)${CONFIG}`], root)).length === 0) {
|
|
193
228
|
yield* Console.log(`${NAME}: the head holds no ${CONFIG}, so no file is measured`);
|
|
194
229
|
return true;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git, commitOf, parentOrEmptyTree, refArgs } from "../core/git.ts";
|
|
4
|
-
import {
|
|
3
|
+
import { git, commitOf, emptyTree, parentOrEmptyTree, refArgs } from "../core/git.ts";
|
|
4
|
+
import { WHOLE_TREE } from "../core/gates.ts";
|
|
5
|
+
import { Usage, runMain } from "../core/main.ts";
|
|
5
6
|
|
|
6
7
|
export const SUPPRESSIONS = ["oxlint-suppressions.json", "stylelint-suppressions.json"] as const;
|
|
7
8
|
|
|
@@ -23,7 +24,7 @@ export class SuppressionsError extends Schema.TaggedError<SuppressionsError>()("
|
|
|
23
24
|
message: Schema.String,
|
|
24
25
|
}) {}
|
|
25
26
|
|
|
26
|
-
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref>";
|
|
27
|
+
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref> | --whole-tree <head-ref>";
|
|
27
28
|
|
|
28
29
|
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
29
30
|
const decodeObject = Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Unknown));
|
|
@@ -124,11 +125,19 @@ export const runTip = Effect.fn("runTip")(function* (tip: string) {
|
|
|
124
125
|
return yield* compareAt(yield* parentOrEmptyTree(tipCommit), tipCommit);
|
|
125
126
|
});
|
|
126
127
|
|
|
128
|
+
export const runWholeTree = Effect.fn("runWholeTree")(function* (head: string) {
|
|
129
|
+
return yield* compareAt(yield* emptyTree(), yield* commitOf(head));
|
|
130
|
+
});
|
|
131
|
+
|
|
127
132
|
const ratchet = Effect.gen(function* () {
|
|
128
133
|
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
129
|
-
|
|
134
|
+
if (first === WHOLE_TREE) {
|
|
135
|
+
if (second === undefined) return yield* new Usage({ message: USAGE });
|
|
136
|
+
const whole = yield* runWholeTree(second);
|
|
137
|
+
yield* Console.log(report(whole));
|
|
138
|
+
return whole.rises.length === 0;
|
|
139
|
+
}
|
|
130
140
|
const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
|
|
131
|
-
|
|
132
141
|
yield* Console.log(report(result));
|
|
133
142
|
return result.rises.length === 0;
|
|
134
143
|
});
|
package/src/core/gates.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type KitGate = {
|
|
|
26
26
|
readonly args?: readonly string[];
|
|
27
27
|
readonly env?: Readonly<Record<string, string>>;
|
|
28
28
|
readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
|
|
29
|
+
readonly night?: true;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export const ENTRY_POINT: Program = { bin: "checks-lint", script: "core/lint.ts" };
|
|
@@ -48,8 +49,10 @@ const STYLESHEET: TrackedContent = { pathspecs: ["*.css"], content: "a styleshee
|
|
|
48
49
|
|
|
49
50
|
const COPY_DECLARATION: TrackedContent = { pathspecs: ["copy.json"], content: "a copy declaration" };
|
|
50
51
|
|
|
52
|
+
export const WHOLE_TREE = "--whole-tree";
|
|
53
|
+
|
|
51
54
|
export const KIT_GATES = [
|
|
52
|
-
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
|
|
55
|
+
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE, night: true },
|
|
53
56
|
{ bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
54
57
|
{ bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
55
58
|
{ bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
@@ -57,14 +60,14 @@ export const KIT_GATES = [
|
|
|
57
60
|
{ bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
|
|
58
61
|
{ bin: "checks-css-size", vector: "quality", file: "css-size.ts", reads: "tree", appliesTo: STYLESHEET },
|
|
59
62
|
{ bin: "checks-no-plain-js", vector: "quality", file: "no-plain-js.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
60
|
-
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
63
|
+
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY, night: true },
|
|
61
64
|
{ bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
62
65
|
{ bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
63
66
|
{ bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
|
|
64
67
|
{ bin: "checks-copy", vector: "docs", file: "copy.ts", reads: "tree", appliesTo: COPY_DECLARATION },
|
|
65
|
-
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
68
|
+
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE, night: true },
|
|
66
69
|
{ bin: "checks-graphwalk", vector: "complexity", file: "graphwalk.ts", reads: "range", appliesTo: LINTED_SOURCE },
|
|
67
|
-
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
70
|
+
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY, night: true },
|
|
68
71
|
{
|
|
69
72
|
bin: "checks-advisories",
|
|
70
73
|
vector: "dependencies",
|
|
@@ -74,3 +77,7 @@ export const KIT_GATES = [
|
|
|
74
77
|
appliesTo: BUN_LOCKFILE,
|
|
75
78
|
}
|
|
76
79
|
] as const satisfies readonly KitGate[];
|
|
80
|
+
|
|
81
|
+
export const NIGHT_GATES: readonly KitGate[] = KIT_GATES.filter((gate) => "night" in gate);
|
|
82
|
+
|
|
83
|
+
export const PR_GATES: readonly KitGate[] = KIT_GATES.filter((gate) => !("night" in gate));
|
package/src/core/git.ts
CHANGED
|
@@ -168,7 +168,9 @@ export const changedLines = Effect.fn("changedLines")(function* (
|
|
|
168
168
|
return parseAddedLines(diff);
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
const emptyTree = (
|
|
171
|
+
export const emptyTree = Effect.fn("emptyTree")(function* (cwd?: string) {
|
|
172
|
+
return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
|
|
173
|
+
});
|
|
172
174
|
|
|
173
175
|
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
174
176
|
const listed = yield* git(["diff", "--name-only", "-z", "--no-renames", yield* emptyTree(cwd), rev, "--", ...pathspecs], cwd);
|
package/src/core/lint.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
|
-
import { EVERY_REPOSITORY,
|
|
4
|
+
import { EVERY_REPOSITORY, NIGHT_GATES, PR_GATES, WHOLE_TREE, type KitGate } from "./gates.ts";
|
|
5
5
|
import { changedPaths, defaultBranch, git } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
7
7
|
import { FAST_TREE_GATES, selectScope } from "./scope.ts";
|
|
@@ -22,7 +22,8 @@ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecide
|
|
|
22
22
|
}) {}
|
|
23
23
|
|
|
24
24
|
const NAME = "checks-lint";
|
|
25
|
-
const USAGE = "usage: lint.ts [--full | --desk] [<base-ref> <head-ref>]";
|
|
25
|
+
const USAGE = "usage: lint.ts [--full | --desk] [<base-ref> <head-ref>] | --night";
|
|
26
|
+
const NIGHT = "--night";
|
|
26
27
|
const PULL_REQUEST_EVENT = "pull_request";
|
|
27
28
|
const SHALLOW_HINT = "a CI checkout needs actions/checkout fetch-depth: 0";
|
|
28
29
|
|
|
@@ -159,11 +160,16 @@ function outcomeOf(exitCode: number): Outcome {
|
|
|
159
160
|
return exitCode === 1 ? "violated" : "undecided";
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
163
|
+
const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range, wholeTree: boolean) {
|
|
163
164
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
164
165
|
const script = (yield* Path.Path).join(import.meta.dir, "..", gate.vector, gate.file);
|
|
165
166
|
const program = gate.file.endsWith(".sh") ? "sh" : process.execPath;
|
|
166
|
-
const args =
|
|
167
|
+
const args =
|
|
168
|
+
gate.reads === "range"
|
|
169
|
+
? wholeTree
|
|
170
|
+
? [script, WHOLE_TREE, ...range.refs]
|
|
171
|
+
: [script, ...range.refs]
|
|
172
|
+
: [script, ...(gate.args ?? [])];
|
|
167
173
|
const extra = gate.env === undefined ? {} : { env: gate.env, extendEnv: true };
|
|
168
174
|
const exitCode = yield* spawner
|
|
169
175
|
.exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit", ...extra }))
|
|
@@ -175,6 +181,11 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
|
|
|
175
181
|
return { gate, outcome: outcomeOf(exitCode) };
|
|
176
182
|
});
|
|
177
183
|
|
|
184
|
+
const wholeTreeRange = Effect.fn("wholeTreeRange")(function* () {
|
|
185
|
+
const head = yield* commitOf("HEAD");
|
|
186
|
+
return { refs: [head], source: "the whole tree at HEAD" } satisfies Range;
|
|
187
|
+
});
|
|
188
|
+
|
|
178
189
|
const scopedGates = Effect.fn("scopedGates")(function* (range: Range, root: string) {
|
|
179
190
|
if (range.refs.length === 1) return { bins: undefined, detail: "the full run, since a lone tip scopes nothing" };
|
|
180
191
|
const [base, head] = range.refs;
|
|
@@ -187,39 +198,62 @@ const scopedGates = Effect.fn("scopedGates")(function* (range: Range, root: stri
|
|
|
187
198
|
return { bins: scope.gates, detail: "" };
|
|
188
199
|
});
|
|
189
200
|
|
|
201
|
+
const pullRequestSelection = Effect.fn("pullRequestSelection")(function* (
|
|
202
|
+
split: { readonly mode: LintMode; readonly rest: readonly string[] },
|
|
203
|
+
range: Range,
|
|
204
|
+
root: string,
|
|
205
|
+
applicable: readonly KitGate[],
|
|
206
|
+
) {
|
|
207
|
+
if (split.mode === "desk") {
|
|
208
|
+
return {
|
|
209
|
+
bins: applicable.filter((gate) => FAST_TREE_GATES.includes(gate.bin)).map((gate) => gate.bin),
|
|
210
|
+
detail: "the desk run",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (split.mode === "full") {
|
|
214
|
+
return { bins: applicable.map((gate) => gate.bin), detail: "the full run (--full)" };
|
|
215
|
+
}
|
|
216
|
+
return yield* scopedGates(range, root);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
function selectionDetail(night: boolean, mode: LintMode, scopeDetail: string, count: number): string | undefined {
|
|
220
|
+
if (night) return undefined;
|
|
221
|
+
if (mode === "scoped" && scopeDetail === "") return `${count} gate(s) for this change`;
|
|
222
|
+
return scopeDetail === "" ? undefined : scopeDetail;
|
|
223
|
+
}
|
|
224
|
+
|
|
190
225
|
const lint = Effect.gen(function* () {
|
|
191
|
-
const
|
|
226
|
+
const raw = process.argv.slice(2);
|
|
227
|
+
const night = raw.includes(NIGHT);
|
|
228
|
+
if (night && raw.length > 1) return yield* new Usage({ message: USAGE });
|
|
229
|
+
const split = night ? { mode: "full" as const, rest: [] as readonly string[] } : splitMode(raw);
|
|
192
230
|
if (split === undefined) return yield* new Usage({ message: USAGE });
|
|
193
231
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
194
|
-
const range = yield* resolveRange(split.rest);
|
|
232
|
+
const range = night ? yield* wholeTreeRange() : yield* resolveRange(split.rest);
|
|
195
233
|
yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
|
|
196
|
-
const applicable = yield* Effect.filter(
|
|
234
|
+
const applicable = yield* Effect.filter(night ? NIGHT_GATES : PR_GATES, (gate) =>
|
|
197
235
|
gate.appliesTo === EVERY_REPOSITORY
|
|
198
236
|
? Effect.succeed(true)
|
|
199
237
|
: git(["ls-files", "--", ...gate.appliesTo.pathspecs], root).pipe(Effect.map((files) => files.trim() !== "")),
|
|
200
238
|
);
|
|
201
|
-
const scoped =
|
|
202
|
-
? {
|
|
203
|
-
|
|
204
|
-
detail: "the desk run",
|
|
205
|
-
}
|
|
206
|
-
: split.mode === "full"
|
|
207
|
-
? { bins: applicable.map((gate) => gate.bin), detail: "the full run (--full)" }
|
|
208
|
-
: yield* scopedGates(range, root);
|
|
239
|
+
const scoped = night
|
|
240
|
+
? { bins: applicable.map((gate) => gate.bin) as readonly string[] | undefined, detail: "" }
|
|
241
|
+
: yield* pullRequestSelection(split, range, root, applicable);
|
|
209
242
|
const bins = scoped.bins;
|
|
210
243
|
const gates = bins === undefined ? applicable : applicable.filter((gate) => bins.includes(gate.bin));
|
|
211
|
-
const detail = split.mode
|
|
212
|
-
yield* Console.log(`${NAME}: ${detail}`);
|
|
213
|
-
const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
|
|
244
|
+
const detail = selectionDetail(night, split.mode, scoped.detail, gates.length);
|
|
245
|
+
if (detail !== undefined) yield* Console.log(`${NAME}: ${detail}`);
|
|
246
|
+
const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range, night));
|
|
214
247
|
const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
|
|
215
248
|
if (failed.length === 0) {
|
|
216
249
|
yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
|
|
217
250
|
return true;
|
|
218
251
|
}
|
|
219
|
-
const
|
|
252
|
+
const names = failed.map((verdict) => verdict.gate.bin).join(", ");
|
|
220
253
|
if (failed.every((verdict) => verdict.outcome === "undecided")) {
|
|
221
|
-
return yield* new GatesUndecided({ message:
|
|
254
|
+
return yield* new GatesUndecided({ message: `${failed.length} of ${gates.length} gate(s) undecided: ${names}` });
|
|
222
255
|
}
|
|
256
|
+
const summary = `${failed.length} of ${gates.length} gate(s) failed: ${names}`;
|
|
223
257
|
yield* Console.error(`${NAME}: ${summary}`);
|
|
224
258
|
return false;
|
|
225
259
|
});
|
|
@@ -19,7 +19,8 @@ export type PreviousNight = { readonly url: string; readonly record: NightRecord
|
|
|
19
19
|
export type Finding =
|
|
20
20
|
| { readonly kind: "mutation"; readonly file: string; readonly before: Score; readonly after: Score; readonly since: string }
|
|
21
21
|
| { readonly kind: "test"; readonly file: string; readonly name: string }
|
|
22
|
-
| { readonly kind: "crash"; readonly file: string }
|
|
22
|
+
| { readonly kind: "crash"; readonly file: string }
|
|
23
|
+
| { readonly kind: "gates"; readonly file: string };
|
|
23
24
|
|
|
24
25
|
function scoreOf(file: ReportFile): Score {
|
|
25
26
|
let detected = 0;
|
|
@@ -45,6 +46,7 @@ function fell(before: Score, after: Score): boolean {
|
|
|
45
46
|
export function keyOf(finding: Finding): string {
|
|
46
47
|
if (finding.kind === "mutation") return `mutation:${finding.file}`;
|
|
47
48
|
if (finding.kind === "test") return `test:${finding.file}${NAME_SEPARATOR}${finding.name}`;
|
|
49
|
+
if (finding.kind === "gates") return `gates:${finding.file}`;
|
|
48
50
|
return `test:${finding.file}`;
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -65,8 +67,23 @@ function testFindings(reports: readonly TestReport[]): readonly Finding[] {
|
|
|
65
67
|
return [...new Map(failing.map((finding) => [keyOf(finding), finding])).values()];
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight): readonly Finding[] {
|
|
69
|
-
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests)].sort((one, other) =>
|
|
70
|
+
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight, gates: readonly Finding[] = []): readonly Finding[] {
|
|
71
|
+
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...gates].sort((one, other) =>
|
|
72
|
+
keyOf(one).localeCompare(keyOf(other)),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const FAILED_GATES = /of \d+ gate\(s\) failed: (.+)/;
|
|
77
|
+
const PASSED_GATES = /gate\(s\) pass/;
|
|
78
|
+
|
|
79
|
+
export function gatesOf(log: string): readonly Finding[] {
|
|
80
|
+
const names = log
|
|
81
|
+
.split("\n")
|
|
82
|
+
.flatMap((line) => FAILED_GATES.exec(line)?.[1]?.split(",") ?? [])
|
|
83
|
+
.map((name) => name.trim())
|
|
84
|
+
.filter((name) => name !== "");
|
|
85
|
+
if (names.length > 0) return [...new Map(names.map((file): [string, Finding] => [file, { kind: "gates", file }])).values()];
|
|
86
|
+
return PASSED_GATES.test(log) ? [] : [{ kind: "gates", file: "checks-lint" }];
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
export function percent(score: Score): string {
|
package/src/testing/night-run.ts
CHANGED
|
@@ -5,11 +5,11 @@ import { DEFAULT_BRANCH } from "../core/gates.ts";
|
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
|
|
7
7
|
import { parseReport as parseMutationReport } from "./mutation-compare.ts";
|
|
8
|
-
import { type Finding, findingsOf, keyOf, NightRecordSchema, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
8
|
+
import { type Finding, findingsOf, gatesOf, keyOf, NightRecordSchema, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
9
9
|
import { parseReport as parseTestReport } from "./test-report.ts";
|
|
10
10
|
|
|
11
11
|
const NAME = "checks-night-run";
|
|
12
|
-
const USAGE = `usage: ${NAME} [--mutation <mutation-report>] [--tests <junit-report>]
|
|
12
|
+
const USAGE = `usage: ${NAME} [--mutation <mutation-report>] [--tests <junit-report>]... [--gates <lint-log>]`;
|
|
13
13
|
export const LABEL = "night-run";
|
|
14
14
|
const LABEL_COLOR = "5319e7";
|
|
15
15
|
const LABEL_DESCRIPTION = "A regression a night run on main found";
|
|
@@ -23,19 +23,22 @@ class NightRunFailure extends Schema.TaggedError<NightRunFailure>()("NightRunFai
|
|
|
23
23
|
message: Schema.String,
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
26
|
-
export type Options = { readonly mutation: Option.Option<string>; readonly tests: readonly string[] };
|
|
26
|
+
export type Options = { readonly mutation: Option.Option<string>; readonly tests: readonly string[]; readonly gates: Option.Option<string> };
|
|
27
27
|
|
|
28
28
|
export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]): Effect.fn.Return<Options, Usage> {
|
|
29
29
|
const mutation: string[] = [];
|
|
30
30
|
const tests: string[] = [];
|
|
31
|
+
const gates: string[] = [];
|
|
31
32
|
for (let at = 0; at < argv.length; at += 2) {
|
|
32
33
|
const [flag, value] = [argv[at], argv[at + 1]];
|
|
33
|
-
const into = flag === "--mutation" ? mutation : flag === "--tests" ? tests : undefined;
|
|
34
|
+
const into = flag === "--mutation" ? mutation : flag === "--tests" ? tests : flag === "--gates" ? gates : undefined;
|
|
34
35
|
if (into === undefined || value === undefined || value.startsWith("--")) return yield* new Usage({ message: USAGE });
|
|
35
36
|
into.push(value);
|
|
36
37
|
}
|
|
37
|
-
if (mutation.length > 1 || mutation.length + tests.length === 0)
|
|
38
|
-
|
|
38
|
+
if (mutation.length > 1 || gates.length > 1 || mutation.length + tests.length + gates.length === 0) {
|
|
39
|
+
return yield* new Usage({ message: USAGE });
|
|
40
|
+
}
|
|
41
|
+
return { mutation: Option.fromNullishOr(mutation[0]), tests, gates: Option.fromNullishOr(gates[0]) };
|
|
39
42
|
});
|
|
40
43
|
|
|
41
44
|
export type OpenIssue = { readonly number: number; readonly key: string };
|
|
@@ -102,6 +105,7 @@ const openIssues = Effect.gen(function* () {
|
|
|
102
105
|
function fullTitleOf(finding: Finding): string {
|
|
103
106
|
if (finding.kind === "mutation") return `Night run: mutation score fell in ${finding.file}`;
|
|
104
107
|
if (finding.kind === "test") return `Night run: ${finding.file} > ${finding.name} fails`;
|
|
108
|
+
if (finding.kind === "gates") return `Night run: nightly gate ${finding.file} failed`;
|
|
105
109
|
return `Night run: ${finding.file} crashed its test worker`;
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -125,6 +129,7 @@ export function findingText(finding: Finding, run: string): string {
|
|
|
125
129
|
].join("\n");
|
|
126
130
|
}
|
|
127
131
|
if (finding.kind === "test") return `The night run ${run} failed \`${finding.name}\` in \`${finding.file}\`.`;
|
|
132
|
+
if (finding.kind === "gates") return `The night run ${run} failed nightly gate \`${finding.file}\`.`;
|
|
128
133
|
return `The night run ${run} lost every result of \`${finding.file}\`, because the worker running it crashed.`;
|
|
129
134
|
}
|
|
130
135
|
|
|
@@ -240,8 +245,9 @@ const nightRun = Effect.gen(function* () {
|
|
|
240
245
|
const actions = yield* actionsRun;
|
|
241
246
|
const scores = Option.isSome(options.mutation) ? Option.some(yield* tonightScores(options.mutation.value)) : Option.none<NightRecord["scores"]>();
|
|
242
247
|
const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
|
|
248
|
+
const gates: readonly Finding[] = Option.isSome(options.gates) ? gatesOf(yield* readText(options.gates.value)) : [];
|
|
243
249
|
const previous = Option.isSome(scores) ? yield* previousNight(actions) : Option.none<PreviousNight>();
|
|
244
|
-
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests });
|
|
250
|
+
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, gates);
|
|
245
251
|
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, actions.run) } : { onMain: false, ref: actions.ref };
|
|
246
252
|
if (filing.onMain && Option.isSome(scores)) yield* writeRecord({ scores: scores.value });
|
|
247
253
|
yield* Console.log(report(actions.run, Option.map(scores, (tonight) => Object.keys(tonight).length), previous, findings, filing));
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
3
|
import { commitOf, git, isShallowBoundary, refArgs, writtenAt } from "../core/git.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { WHOLE_TREE } from "../core/gates.ts";
|
|
5
|
+
import { Usage, runMain } from "../core/main.ts";
|
|
5
6
|
import { TEST_FILE } from "./test-layout.ts";
|
|
6
7
|
|
|
7
8
|
export const QUARANTINE = "tests/quarantine/";
|
|
@@ -12,7 +13,7 @@ export class QuarantineError extends Schema.TaggedError<QuarantineError>()("Quar
|
|
|
12
13
|
message: Schema.String,
|
|
13
14
|
}) {}
|
|
14
15
|
|
|
15
|
-
const USAGE = "usage: quarantine-clock.ts <ref> | <base-ref> <head-ref>";
|
|
16
|
+
const USAGE = "usage: quarantine-clock.ts <ref> | <base-ref> <head-ref> | --whole-tree <head-ref>";
|
|
16
17
|
|
|
17
18
|
export type Entry =
|
|
18
19
|
| { readonly kind: "entered"; readonly sha: string; readonly at: number; readonly day: string }
|
|
@@ -133,6 +134,13 @@ export const runHead = Effect.fn("runHead")(function* (root: string, head: strin
|
|
|
133
134
|
|
|
134
135
|
const clock = Effect.gen(function* () {
|
|
135
136
|
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
137
|
+
if (first === WHOLE_TREE) {
|
|
138
|
+
if (second === undefined) return yield* new Usage({ message: USAGE });
|
|
139
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
140
|
+
const result = yield* runHead(root, yield* commitOf(second));
|
|
141
|
+
yield* Console.log(report(result));
|
|
142
|
+
return result.overdue.length === 0;
|
|
143
|
+
}
|
|
136
144
|
if (second !== undefined) yield* commitOf(first);
|
|
137
145
|
const head = yield* commitOf(second ?? first);
|
|
138
146
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|