@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 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 failing test.
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 any count in that file that rises.
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 when a stylesheet's repeated lines grow.
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 a change that raises a count.
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 in the full run when the repository tracks a `.ts`, `.tsx` or `.astro` file.
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 kit lint gate in the selected set over one range and reports each failure.
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 KIT_GATES in src/core/gates.ts and scripts/doc-blocks.ts -->
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 17 gate(s) failed: checks-comment-gate
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 failing test.
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 either the test or the score of both nights.
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 and its long test tiers.
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 only satisfies the range `checks-lint` hands every range gate.
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 over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
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 both ends, so its `path` and `ignore` globs select the files to measure.
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 base and head commits rather than the working tree's file contents.
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 | No measured file repeated more lines. |
44
- | 1 | At least one measured file repeated more lines. |
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 more lines than where the range starts, at 50 tokens and 5 lines:
51
- src/copy.ts: 10 repeated line(s), up from 0
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 over each pull request's range when the repository tracks a `.ts` or `.tsx` file.
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 over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
50
- A repository with neither file passes, since both ends count as empty.
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.56.1",
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 { runMain } from "../core/main.ts";
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
- ` ${file}: ${after} repeated line(s), up from ${before}`,
170
- ...clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`),
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
- const notice =
180
- advisory.size === 0
181
- ? []
182
- : [
183
- `${NAME}: advisory, ${advisory.size} file(s) repeat lines the hold does not fail:`,
184
- ...[...advisory].map(([file, lines]) => ` ${file}: ${lines} repeated line(s)`),
185
- ];
186
- return [...verdict, ...notice].join("\n");
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 { base, head } = yield* rangeFromArgs(process.argv.slice(2), USAGE, root);
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 { runMain } from "../core/main.ts";
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 = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
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, KIT_GATES, type KitGate } from "./gates.ts";
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 = gate.reads === "range" ? [script, ...range.refs] : [script, ...(gate.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 split = splitMode(process.argv.slice(2));
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(KIT_GATES, (gate) =>
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 = split.mode === "desk"
202
- ? {
203
- bins: applicable.filter((gate) => FAST_TREE_GATES.includes(gate.bin)).map((gate) => gate.bin),
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 === "scoped" && bins !== undefined ? `${gates.length} gate(s) for this change` : scoped.detail;
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 summary = `${failed.length} of ${gates.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
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: summary });
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) => keyOf(one).localeCompare(keyOf(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 {
@@ -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) return yield* new Usage({ message: USAGE });
38
- return { mutation: Option.fromNullishOr(mutation[0]), tests };
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 { runMain } from "../core/main.ts";
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();