@avi2dg/checks 0.62.1 → 0.64.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 +24 -0
- package/docs/bins.md +2 -1
- package/docs/gates/checks-night-run.md +52 -14
- package/package.json +1 -1
- package/src/core/git.ts +9 -0
- package/src/core/lint.ts +2 -2
- package/src/delivery/docs-only.ts +2 -4
- package/src/delivery/mutation-job.ts +14 -17
- package/src/testing/mutation-scope.ts +1 -3
- package/src/testing/night-run-findings.ts +26 -3
- package/src/testing/night-run.ts +100 -25
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.64.0
|
|
6
|
+
|
|
7
|
+
Released 2026-11-03.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **testing:** shard the night mutation run and file each missing report [#27](https://github.com/avi2d/checks/pull/27)
|
|
12
|
+
|
|
13
|
+
## 0.63.1
|
|
14
|
+
|
|
15
|
+
Released 2026-11-03.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
|
|
19
|
+
- **testing:** note a cleared mutation issue once [#29](https://github.com/avi2d/checks/pull/29)
|
|
20
|
+
|
|
21
|
+
## 0.63.0
|
|
22
|
+
|
|
23
|
+
Released 2026-11-03.
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- **testing:** close cleared test and gate issues and note cleared mutation issues [#28](https://github.com/avi2d/checks/pull/28)
|
|
28
|
+
|
|
5
29
|
## 0.62.1
|
|
6
30
|
|
|
7
31
|
Released 2026-11-02.
|
package/docs/bins.md
CHANGED
|
@@ -50,7 +50,8 @@ These bins run on their own:
|
|
|
50
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.
|
|
51
51
|
- [`checks-mutation-scope`](gates/checks-mutation-scope.md) turns a branch's diff into the files its mutation runs mutate.
|
|
52
52
|
- [`checks-subsumed-tests`](gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
|
|
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,
|
|
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, each failed nightly gate, and each missing mutation report.
|
|
54
|
+
It closes the issue of a test, a gate or a missing mutation report that a later night no longer reports, and it comments on a mutation issue it no longer reports without closing it, but only when a previous night exists and every requested mutation report is present.
|
|
54
55
|
- [`checks-changelog`](gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
|
|
55
56
|
- [`checks-release-notes`](gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
|
|
56
57
|
- [`checks-release-report`](gates/checks-release-report.md) tells whether the history holds unreleased features or fixes since the last tag.
|
|
@@ -4,11 +4,13 @@ audience: consumers
|
|
|
4
4
|
---
|
|
5
5
|
# checks-night-run
|
|
6
6
|
|
|
7
|
-
`checks-night-run` turns a night run on `main` into GitHub
|
|
7
|
+
`checks-night-run` turns each finding of a night run on `main` into a GitHub issue.
|
|
8
|
+
A finding is a file whose mutation score fell, a failing test, a failed nightly gate or a missing mutation report.
|
|
9
|
+
It closes the issue of a test, gate or missing mutation report that a later night no longer reports, and it comments on a mutation issue it no longer reports without closing it, but only when a previous night exists and every requested mutation report is present.
|
|
8
10
|
|
|
9
11
|
## What it checks
|
|
10
12
|
|
|
11
|
-
It scores each file of tonight's Stryker
|
|
13
|
+
It scores each file of tonight's Stryker reports as Stryker does, the `Killed` and `Timeout` mutants over every mutant but the `Ignored`, `CompileError`, `RuntimeError` and `Pending` ones.
|
|
12
14
|
It compares each file's score with the same file's score from the previous night.
|
|
13
15
|
A file whose score is lower than the previous night's is a finding.
|
|
14
16
|
A file only one night holds is no finding, since it lacks one of the two scores.
|
|
@@ -20,7 +22,10 @@ A log where every named gate is undecided files `checks-lint` itself, with no pe
|
|
|
20
22
|
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.
|
|
21
23
|
A file whose test worker crashed is one finding, since Bun reports none of its tests.
|
|
22
24
|
With no previous night it compares no score and still files each failing test.
|
|
25
|
+
Each mutation report `--mutation` names that does not exist is a finding, since the mutation job that writes it failed, timed out or was cancelled.
|
|
23
26
|
With no mutation report it compares and records no score and still files each failing test.
|
|
27
|
+
With some mutation reports missing it compares the files the others hold and records no score.
|
|
28
|
+
The next night then compares every file with the last night that scored them all.
|
|
24
29
|
|
|
25
30
|
Each finding has a key that stays the same from night to night:
|
|
26
31
|
|
|
@@ -30,6 +35,7 @@ Each finding has a key that stays the same from night to night:
|
|
|
30
35
|
| a failing test | `test:<file> > <describe> > <name>` |
|
|
31
36
|
| a file whose test worker crashed | `test:<file>` |
|
|
32
37
|
| a nightly gate that failed | `gates:<gate>` |
|
|
38
|
+
| a missing mutation report | `mutation-report:<path>` |
|
|
33
39
|
|
|
34
40
|
## How it files a finding
|
|
35
41
|
|
|
@@ -40,31 +46,60 @@ Any other finding opens one issue labelled `night-run`.
|
|
|
40
46
|
The issue and the comment name tonight's run, the file and the test, the score of both nights or the failed gate.
|
|
41
47
|
The body holds the file, the full test name or the failed gate, and the finding key.
|
|
42
48
|
A title longer than GitHub's 256 characters is cut to at most 256 and ends in `…`.
|
|
43
|
-
A night with no finding
|
|
49
|
+
A night with no finding creates no label and writes no issue, and only reads the open issues to close any test, gate or mutation-report issue whose finding is gone and, when a previous night exists and every requested mutation report is present, to comment on any mutation issue whose finding is gone.
|
|
44
50
|
Closing an issue ends it, and the same finding on a later night opens a new one.
|
|
51
|
+
|
|
52
|
+
## How it closes an issue
|
|
53
|
+
|
|
54
|
+
An open test, gate or mutation-report issue whose key tonight's findings lack gets one comment naming tonight's run and the key.
|
|
55
|
+
It then closes as completed.
|
|
56
|
+
When a previous night exists and every requested mutation report is present, an open mutation issue whose key tonight's findings lack gets one comment that names tonight's run and the key, and stays open.
|
|
57
|
+
A closed issue is no longer read, so a later night without the finding comments on it no more.
|
|
58
|
+
A comment that reports the finding carries a reported marker, and a note that a mutation finding is gone carries a noted marker.
|
|
59
|
+
A mutation issue whose latest marked comment already notes the finding gone gets no further note.
|
|
60
|
+
So a mutation finding gone for many nights is noted once, and noted again only after a later night reports it again.
|
|
61
|
+
A person's comment carries no marker, so it never makes the run note an issue twice.
|
|
62
|
+
It judges a key only from a report it read tonight:
|
|
63
|
+
|
|
64
|
+
| Key | Closes when |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `test:<file> > <describe> > <name>` | tonight read a JUnit report, the test is not failing, and `<file>`'s worker did not crash |
|
|
67
|
+
| `test:<file>` | tonight read a JUnit report and `<file>`'s worker did not crash |
|
|
68
|
+
| `gates:<gate>` | tonight read a lint log that passes or names its failed gates, and `<gate>` is not among them |
|
|
69
|
+
| `mutation-report:<path>` | tonight read the mutation report at `<path>` |
|
|
70
|
+
| `mutation:<file>` | never |
|
|
71
|
+
|
|
72
|
+
A crashed worker loses every result of its file, so the issue of a test in that file stays open.
|
|
73
|
+
A lint log that is undecided or never finished says nothing of any one gate, so every gate issue stays open.
|
|
74
|
+
A mutation finding is a fall from one night to the next.
|
|
75
|
+
A later night that does not fall again still holds the lower score.
|
|
76
|
+
Only a night that compared scores with every requested mutation report present comments on the mutation issue without closing it, since a score can recover and drop again.
|
|
77
|
+
So a person closes a mutation issue once the score is back.
|
|
78
|
+
An issue with no key in its body is never read, so a person's issue under the `night-run` label is never commented on or closed.
|
|
79
|
+
A run off `main` comments on and closes no issue.
|
|
45
80
|
Agents and people fix each issue as a task of its own, so no pull request waits for a night run.
|
|
46
81
|
A run on any ref but `refs/heads/main` files no issue and records no score, and lists each finding in its log only.
|
|
47
82
|
|
|
48
83
|
## What it reads
|
|
49
84
|
|
|
50
|
-
It reads tonight's Stryker `mutation.json`
|
|
85
|
+
It reads tonight's Stryker `mutation.json` reports and any number of JUnit reports, such as the ones `checks-test --junit=<file>` keeps, as [checks-test](checks-test.md#arguments) says.
|
|
51
86
|
It reads the lint log `--gates` names for the `gate(s) failed:` line `checks-lint --night` prints.
|
|
52
87
|
It scans the newest 30 successful runs of the same workflow on `main` from newest to oldest.
|
|
53
88
|
It takes the first run whose `night-run` artifact still exists as the previous night.
|
|
54
89
|
That artifact holds `night-run.json`, the scores the run recorded.
|
|
55
90
|
When no scanned run holds the artifact it compares no score.
|
|
56
91
|
A run on another branch never becomes the previous night.
|
|
57
|
-
On `main` with
|
|
92
|
+
On `main` with every mutation report present it writes tonight's scores to `reports/night-run.json`, and the workflow uploads that file as the `night-run` artifact.
|
|
58
93
|
It reads the run from `GITHUB_SERVER_URL`, `GITHUB_REPOSITORY`, `GITHUB_RUN_ID`, `GITHUB_WORKFLOW_REF` and `GITHUB_REF`, so it runs only inside a GitHub Actions job.
|
|
59
94
|
It calls `gh` with the job's `GH_TOKEN`, which needs `actions: read` for the previous night and `issues: write` for the issues.
|
|
60
95
|
|
|
61
96
|
## Arguments
|
|
62
97
|
|
|
63
98
|
```sh
|
|
64
|
-
checks-night-run [--mutation <mutation-report>] [--tests <junit-report>]... [--gates <lint-log>]
|
|
99
|
+
checks-night-run [--mutation <mutation-report>]... [--tests <junit-report>]... [--gates <lint-log>]
|
|
65
100
|
```
|
|
66
101
|
|
|
67
|
-
`--mutation` names tonight's Stryker
|
|
102
|
+
`--mutation` names one of tonight's Stryker reports and is given once per report the workflow's mutation jobs write, whether or not each job wrote it.
|
|
68
103
|
`--tests` names one JUnit report and is given once per report.
|
|
69
104
|
`--gates` names tonight's `checks-lint --night` log and is given at most once.
|
|
70
105
|
At least one report is given.
|
|
@@ -73,7 +108,7 @@ At least one report is given.
|
|
|
73
108
|
|
|
74
109
|
| Code | When |
|
|
75
110
|
| --- | --- |
|
|
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 |
|
|
111
|
+
| 0 | it filed every finding, closed each issue whose finding is gone and recorded tonight's scores, it found nothing to file, or the run is off `main` and it listed each finding |
|
|
77
112
|
| 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 |
|
|
78
113
|
|
|
79
114
|
It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
@@ -84,6 +119,7 @@ It never exits 1, because a finding becomes an issue rather than a failed run.
|
|
|
84
119
|
checks-night-run: the night run https://github.com/acme/widget/actions/runs/12 compared with the night run https://github.com/acme/widget/actions/runs/11; 2 finding(s)
|
|
85
120
|
opened #31 for mutation:src/cart.ts
|
|
86
121
|
commented on #27 for test:tests/unit/cart.test.ts > totals > rounds half to even
|
|
122
|
+
closed #24 for gates:checks-repetition
|
|
87
123
|
checks-night-run: recorded 42 file score(s) in reports/night-run.json
|
|
88
124
|
```
|
|
89
125
|
|
|
@@ -168,10 +204,9 @@ jobs:
|
|
|
168
204
|
with:
|
|
169
205
|
bun-version-file: .bun-version
|
|
170
206
|
- run: bun install --frozen-lockfile
|
|
171
|
-
-
|
|
172
|
-
uses: actions/download-artifact@v4
|
|
207
|
+
- uses: actions/download-artifact@v4
|
|
173
208
|
with:
|
|
174
|
-
|
|
209
|
+
pattern: night-run-mutation
|
|
175
210
|
path: reports/mutation
|
|
176
211
|
- if: needs.gates.result == 'success'
|
|
177
212
|
uses: actions/download-artifact@v4
|
|
@@ -184,7 +219,7 @@ jobs:
|
|
|
184
219
|
path: reports/night-run-tests
|
|
185
220
|
- env:
|
|
186
221
|
GH_TOKEN: ${{ github.token }}
|
|
187
|
-
run: bunx checks-night-run
|
|
222
|
+
run: bunx checks-night-run --mutation reports/mutation/night-run-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' || '' }}
|
|
188
223
|
- uses: actions/upload-artifact@v4
|
|
189
224
|
with:
|
|
190
225
|
name: night-run
|
|
@@ -200,7 +235,10 @@ Each `|| true` lets the job go on to the upload after a failing test, since that
|
|
|
200
235
|
A tier that writes no report fails the report job, because `checks-night-run` cannot read it.
|
|
201
236
|
The gates job runs the nightly lint gates and keeps the log, since a failing gate becomes an issue.
|
|
202
237
|
The report job runs whenever the tests job succeeds, so a failing test that also stops Stryker still becomes an issue.
|
|
203
|
-
It passes `--mutation
|
|
238
|
+
It always passes `--mutation`, so a mutation job that failed or timed out leaves a missing report that becomes an issue.
|
|
239
|
+
The download names the artifact by `pattern`, which downloads nothing rather than failing when the artifact is missing.
|
|
240
|
+
A full run too long for one job splits into a matrix of shards.
|
|
241
|
+
Each shard uploads its own artifact under the one pattern, and each report gets its own `--mutation`.
|
|
204
242
|
It passes `--gates` only when the gates job succeeded, and the failed gates job still fails the run.
|
|
205
243
|
The concurrency group queues a run started by hand behind the scheduled one, so two runs never file the same finding twice.
|
|
206
244
|
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.
|
|
@@ -208,7 +246,7 @@ A dispatch on a branch runs both jobs and lists its findings in the report job's
|
|
|
208
246
|
A public repository keeps `runs-on: ubuntu-latest` on every job.
|
|
209
247
|
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`.
|
|
210
248
|
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the tests, gates, and report jobs.
|
|
211
|
-
A successful run uploads `night-run`, so the next night compares with it, and a failed
|
|
249
|
+
A successful run with every mutation report present uploads `night-run`, so the next night compares with it, and a run that failed or missed a report uploads nothing a later night reads.
|
|
212
250
|
The artifact keeps 90 days, so a gap of nights still leaves a previous night to compare with.
|
|
213
251
|
|
|
214
252
|
## Related topics
|
package/package.json
CHANGED
package/src/core/git.ts
CHANGED
|
@@ -107,6 +107,15 @@ export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
|
107
107
|
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
export function pathsOf(change: Change): readonly string[] {
|
|
111
|
+
return change.kind === "renamed" ? [change.from, change.path] : [change.path];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const pathsSinceMergeBase = Effect.fn("pathsSinceMergeBase")(function* (base: string, head: string, cwd?: string) {
|
|
115
|
+
const mergeBase = (yield* git(["merge-base", base, head], cwd)).trim();
|
|
116
|
+
return (yield* changedPaths(mergeBase, head, [], cwd)).flatMap(pathsOf);
|
|
117
|
+
});
|
|
118
|
+
|
|
110
119
|
const ESCAPES: Readonly<Record<string, string>> = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
111
120
|
|
|
112
121
|
// Git quotes a path holding a quote, a backslash or a control character, and ends one holding a space with a tab.
|
package/src/core/lint.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { EVERY_REPOSITORY, NIGHT_GATES, PR_GATES, WHOLE_TREE, type KitGate } from "./gates.ts";
|
|
5
|
-
import { changedPaths, defaultBranch, git } from "./git.ts";
|
|
5
|
+
import { changedPaths, defaultBranch, git, pathsOf } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
7
7
|
import { FAST_TREE_GATES, selectScope } from "./scope.ts";
|
|
8
8
|
|
|
@@ -190,7 +190,7 @@ const scopedGates = Effect.fn("scopedGates")(function* (range: Range, root: stri
|
|
|
190
190
|
if (range.refs.length === 1) return { bins: undefined, detail: "the full run, since a lone tip scopes nothing" };
|
|
191
191
|
const [base, head] = range.refs;
|
|
192
192
|
const changes = yield* changedPaths(base, head, [], root);
|
|
193
|
-
const committed = changes.flatMap(
|
|
193
|
+
const committed = changes.flatMap(pathsOf);
|
|
194
194
|
const working = (yield* git(["diff", "--no-renames", "--name-only", "-z", "HEAD", "--"], root)).split("\0").filter(Boolean);
|
|
195
195
|
const untracked = (yield* git(["ls-files", "--others", "--exclude-standard", "-z"], root)).split("\0").filter(Boolean);
|
|
196
196
|
const scope = selectScope([...committed, ...working, ...untracked]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
-
import {
|
|
3
|
+
import { pathsSinceMergeBase } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
|
|
6
6
|
const NAME = "docs-only";
|
|
@@ -18,9 +18,7 @@ const check = Effect.gen(function* () {
|
|
|
18
18
|
yield* Console.log("docs-only=false");
|
|
19
19
|
return true;
|
|
20
20
|
}
|
|
21
|
-
const
|
|
22
|
-
const changes = yield* changedPaths(mergeBase, head, []);
|
|
23
|
-
const paths = changes.flatMap((change) => (change.kind === "renamed" ? [change.from, change.path] : [change.path]));
|
|
21
|
+
const paths = yield* pathsSinceMergeBase(base, head);
|
|
24
22
|
const full = paths.filter((path) => !isDocsPath(path));
|
|
25
23
|
if (paths.length === 0) {
|
|
26
24
|
yield* Console.error(`${NAME}: the range changes no file, so the full run stays`);
|
|
@@ -143,27 +143,24 @@ function mutationBin([program = "", subcommand]: Command): boolean {
|
|
|
143
143
|
return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
const wanted = new Set<string>();
|
|
148
|
-
const lookup = (file: string) => {
|
|
149
|
-
if (!read.has(file)) wanted.add(file);
|
|
150
|
-
return files.get(file);
|
|
151
|
-
};
|
|
152
|
-
for (const run of runs) runsMutation(run, scripts, lookup);
|
|
153
|
-
return [...wanted];
|
|
154
|
-
}
|
|
146
|
+
type FilesWanted = (runs: readonly Run[], scripts: ReadonlyMap<string, string>, files: ReadonlyMap<string, string>, read: ReadonlySet<string>) => readonly string[];
|
|
155
147
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
148
|
+
type Walk = (run: Run, scripts: ReadonlyMap<string, string>, files: (file: string) => string | undefined) => void;
|
|
149
|
+
|
|
150
|
+
function wantedBy(walk: Walk): FilesWanted {
|
|
151
|
+
return (runs, scripts, files, read) => {
|
|
152
|
+
const wanted = new Set<string>();
|
|
153
|
+
const lookup = (file: string) => {
|
|
154
|
+
if (!read.has(file)) wanted.add(file);
|
|
155
|
+
return files.get(file);
|
|
156
|
+
};
|
|
157
|
+
for (const run of runs) walk(run, scripts, lookup);
|
|
158
|
+
return [...wanted];
|
|
161
159
|
};
|
|
162
|
-
for (const run of runs) runsBin(run, scripts, lookup, () => false, true);
|
|
163
|
-
return [...wanted];
|
|
164
160
|
}
|
|
165
161
|
|
|
166
|
-
|
|
162
|
+
const filesWanted = wantedBy(runsMutation);
|
|
163
|
+
const scheduleFilesWanted = wantedBy((run, scripts, files) => runsBin(run, scripts, files, () => false, true));
|
|
167
164
|
|
|
168
165
|
const readFiles = Effect.fn("readFiles")(function* (root: string, scripts: ReadonlyMap<string, string>, runs: readonly Run[], wanted: FilesWanted) {
|
|
169
166
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { escape, minimatch } from "minimatch";
|
|
4
|
-
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "../core/git.ts";
|
|
4
|
+
import { changedPaths, git, pathsAt, pathsOf, rangeEnds, type Change } from "../core/git.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { importedFile, importsOf, readersOf, resolved, TEST_TREE } from "./mutation-scope-graph.ts";
|
|
7
7
|
|
|
@@ -87,8 +87,6 @@ function scopedNames(change: Change, mutated: (file: string) => boolean): readon
|
|
|
87
87
|
return mutated(change.path) ? [change.path] : [];
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
const pathsOf = (change: Change): readonly string[] => (change.kind === "renamed" ? [change.from, change.path] : [change.path]);
|
|
91
|
-
|
|
92
90
|
export function pickFromDiff(changes: readonly Change[], mutated: (file: string) => boolean): Picked {
|
|
93
91
|
const paths = changes.flatMap(pathsOf);
|
|
94
92
|
return {
|
|
@@ -20,7 +20,8 @@ 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
22
|
| { readonly kind: "crash"; readonly file: string }
|
|
23
|
-
| { readonly kind: "gates"; readonly file: string }
|
|
23
|
+
| { readonly kind: "gates"; readonly file: string }
|
|
24
|
+
| { readonly kind: "mutation-report"; readonly file: string };
|
|
24
25
|
|
|
25
26
|
function scoreOf(file: ReportFile): Score {
|
|
26
27
|
let detected = 0;
|
|
@@ -43,10 +44,14 @@ function fell(before: Score, after: Score): boolean {
|
|
|
43
44
|
return before.valid > 0 && after.valid > 0 && after.detected * before.valid < before.detected * after.valid;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// A night judges a key only from a report it read, since a report it lacks leaves every finding in it absent.
|
|
48
|
+
export type Observed = { readonly tests: boolean; readonly gates: boolean; readonly mutation: boolean; readonly mutationReports: ReadonlySet<string> };
|
|
49
|
+
|
|
46
50
|
export function keyOf(finding: Finding): string {
|
|
47
51
|
if (finding.kind === "mutation") return `mutation:${finding.file}`;
|
|
48
52
|
if (finding.kind === "test") return `test:${finding.file}${NAME_SEPARATOR}${finding.name}`;
|
|
49
53
|
if (finding.kind === "gates") return `gates:${finding.file}`;
|
|
54
|
+
if (finding.kind === "mutation-report") return `mutation-report:${finding.file}`;
|
|
50
55
|
return `test:${finding.file}`;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -67,8 +72,8 @@ function testFindings(reports: readonly TestReport[]): readonly Finding[] {
|
|
|
67
72
|
return [...new Map(failing.map((finding) => [keyOf(finding), finding])).values()];
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight,
|
|
71
|
-
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...
|
|
75
|
+
export function findingsOf(previous: Option.Option<PreviousNight>, tonight: Tonight, others: readonly Finding[] = []): readonly Finding[] {
|
|
76
|
+
return [...scoreFindings(previous, tonight.scores), ...testFindings(tonight.tests), ...others].sort((one, other) =>
|
|
72
77
|
keyOf(one).localeCompare(keyOf(other)),
|
|
73
78
|
);
|
|
74
79
|
}
|
|
@@ -86,6 +91,24 @@ export function gatesOf(log: string): readonly Finding[] {
|
|
|
86
91
|
return PASSED_GATES.test(log) ? [] : [{ kind: "gates", file: "checks-lint" }];
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
export function gatesDecided(log: string): boolean {
|
|
95
|
+
return FAILED_GATES.test(log) || PASSED_GATES.test(log);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A mutation finding is a fall between two nights, so a night without it shows no score recovered.
|
|
99
|
+
export function cleared(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
|
|
100
|
+
if (present.has(key)) return false;
|
|
101
|
+
if (key.startsWith("gates:")) return observed.gates;
|
|
102
|
+
if (key.startsWith("mutation-report:")) return observed.mutationReports.has(key.slice("mutation-report:".length));
|
|
103
|
+
if (!key.startsWith("test:")) return false;
|
|
104
|
+
const [file] = key.slice("test:".length).split(NAME_SEPARATOR);
|
|
105
|
+
return observed.tests && !present.has(`test:${file}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function noted(key: string, present: ReadonlySet<string>, observed: Observed): boolean {
|
|
109
|
+
return key.startsWith("mutation:") && !present.has(key) && observed.mutation;
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
export function percent(score: Score): string {
|
|
90
113
|
return `${((score.detected / score.valid) * 100).toFixed(2)}%`;
|
|
91
114
|
}
|
package/src/testing/night-run.ts
CHANGED
|
@@ -4,12 +4,12 @@ import { ChildProcessSpawner } from "effect/process";
|
|
|
4
4
|
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
|
-
import { parseReport as parseMutationReport } from "./mutation-compare.ts";
|
|
8
|
-
import { type Finding, findingsOf, gatesOf, keyOf, NightRecordSchema, type NightRecord, percent, type PreviousNight, scoresOf, type TestReport } from "./night-run-findings.ts";
|
|
7
|
+
import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
|
|
8
|
+
import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, NightRecordSchema, noted, type Observed, 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>]... [--gates <lint-log>]`;
|
|
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,7 +23,7 @@ class NightRunFailure extends Schema.TaggedError<NightRunFailure>()("NightRunFai
|
|
|
23
23
|
message: Schema.String,
|
|
24
24
|
}) {}
|
|
25
25
|
|
|
26
|
-
export type Options = { readonly mutation:
|
|
26
|
+
export type Options = { readonly mutation: readonly 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[] = [];
|
|
@@ -35,15 +35,15 @@ export const parseArgs = Effect.fnUntraced(function* (argv: readonly string[]):
|
|
|
35
35
|
if (into === undefined || value === undefined || value.startsWith("--")) return yield* new Usage({ message: USAGE });
|
|
36
36
|
into.push(value);
|
|
37
37
|
}
|
|
38
|
-
if (
|
|
38
|
+
if (gates.length > 1 || mutation.length + tests.length + gates.length === 0) {
|
|
39
39
|
return yield* new Usage({ message: USAGE });
|
|
40
40
|
}
|
|
41
|
-
return { mutation
|
|
41
|
+
return { mutation, tests, gates: Option.fromNullishOr(gates[0]) };
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
export type OpenIssue = { readonly number: number; readonly key: string };
|
|
45
45
|
|
|
46
|
-
export type Filed = { readonly key: string; readonly issue: number; readonly action: "opened" | "commented on" };
|
|
46
|
+
export type Filed = { readonly key: string; readonly issue: number; readonly action: "opened" | "commented on" | "closed" };
|
|
47
47
|
|
|
48
48
|
export class Issues extends Context.Service<
|
|
49
49
|
Issues,
|
|
@@ -52,6 +52,8 @@ export class Issues extends Context.Service<
|
|
|
52
52
|
readonly open: Effect.Effect<readonly OpenIssue[], GitHubFailure>;
|
|
53
53
|
readonly create: (title: string, body: string) => Effect.Effect<number, GitHubFailure>;
|
|
54
54
|
readonly comment: (issue: number, body: string) => Effect.Effect<void, GitHubFailure>;
|
|
55
|
+
readonly close: (issue: number) => Effect.Effect<void, GitHubFailure>;
|
|
56
|
+
readonly comments: (issue: number) => Effect.Effect<readonly string[], GitHubFailure>;
|
|
55
57
|
}
|
|
56
58
|
>()("@avi2dg/checks/testing/Issues") {
|
|
57
59
|
static readonly gh = Layer.effect(
|
|
@@ -64,6 +66,8 @@ export class Issues extends Context.Service<
|
|
|
64
66
|
open: provided(openIssues),
|
|
65
67
|
create: (title, body) => provided(gitHubJson(decodeNumbered, "POST", `${REPOSITORY}/issues`, { title, body, labels: [LABEL] })).pipe(Effect.map(({ number }) => number)),
|
|
66
68
|
comment: (issue, body) => provided(gitHubJson(decodeAny, "POST", `${REPOSITORY}/issues/${issue}/comments`, { body })).pipe(Effect.asVoid),
|
|
69
|
+
close: (issue) => provided(gitHubJson(decodeAny, "PATCH", `${REPOSITORY}/issues/${issue}`, { state: "closed", state_reason: "completed" })).pipe(Effect.asVoid),
|
|
70
|
+
comments: (issue) => provided(issueComments(issue)),
|
|
67
71
|
});
|
|
68
72
|
}),
|
|
69
73
|
);
|
|
@@ -75,6 +79,7 @@ const decodeIssues = Schema.decodeUnknownEffect(
|
|
|
75
79
|
Schema.fromJsonString(Schema.Array(Schema.Struct({ number: Schema.Int, body: Schema.NullOr(Schema.String), pull_request: Schema.optionalKey(Schema.Unknown) }))),
|
|
76
80
|
);
|
|
77
81
|
const decodeKey = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String));
|
|
82
|
+
const decodeComments = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Schema.Struct({ body: Schema.String }))));
|
|
78
83
|
|
|
79
84
|
const MARKER = /<!-- checks-night-run ("(?:[^"\\]|\\.)*") -->/;
|
|
80
85
|
|
|
@@ -102,10 +107,20 @@ const openIssues = Effect.gen(function* () {
|
|
|
102
107
|
}
|
|
103
108
|
});
|
|
104
109
|
|
|
110
|
+
const issueComments = Effect.fn("issueComments")(function* (issue: number) {
|
|
111
|
+
const found: string[] = [];
|
|
112
|
+
for (let page = 1; ; page += 1) {
|
|
113
|
+
const batch = yield* gitHubJson(decodeComments, "GET", `${REPOSITORY}/issues/${issue}/comments?per_page=${PAGE_SIZE}&page=${page}`);
|
|
114
|
+
found.push(...batch.map(({ body }) => body));
|
|
115
|
+
if (batch.length < PAGE_SIZE) return found;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
105
119
|
function fullTitleOf(finding: Finding): string {
|
|
106
120
|
if (finding.kind === "mutation") return `Night run: mutation score fell in ${finding.file}`;
|
|
107
121
|
if (finding.kind === "test") return `Night run: ${finding.file} > ${finding.name} fails`;
|
|
108
122
|
if (finding.kind === "gates") return `Night run: nightly gate ${finding.file} failed`;
|
|
123
|
+
if (finding.kind === "mutation-report") return `Night run: no mutation report at ${finding.file}`;
|
|
109
124
|
return `Night run: ${finding.file} crashed its test worker`;
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -130,6 +145,7 @@ export function findingText(finding: Finding, run: string): string {
|
|
|
130
145
|
}
|
|
131
146
|
if (finding.kind === "test") return `The night run ${run} failed \`${finding.name}\` in \`${finding.file}\`.`;
|
|
132
147
|
if (finding.kind === "gates") return `The night run ${run} failed nightly gate \`${finding.file}\`.`;
|
|
148
|
+
if (finding.kind === "mutation-report") return `The night run ${run} found no mutation report at \`${finding.file}\`: the mutation job that writes it failed, timed out or was cancelled, so no file it mutates was scored.`;
|
|
133
149
|
return `The night run ${run} lost every result of \`${finding.file}\`, because the worker running it crashed.`;
|
|
134
150
|
}
|
|
135
151
|
|
|
@@ -137,12 +153,32 @@ export function issueBody(finding: Finding, run: string): string {
|
|
|
137
153
|
return `${findingText(finding, run)}\n\nFinding key: \`${keyOf(finding)}\`\n\n${marker(keyOf(finding))}\n`;
|
|
138
154
|
}
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
const REPORTED = "<!-- checks-night-run reported -->";
|
|
157
|
+
const NOTED = "<!-- checks-night-run noted -->";
|
|
158
|
+
|
|
159
|
+
export function reportedText(finding: Finding, run: string): string {
|
|
160
|
+
return `${findingText(finding, run)}\n\n${REPORTED}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The issue body reports the finding too, so an issue with no marked comment is not yet noted.
|
|
164
|
+
export function alreadyNoted(comments: readonly string[]): boolean {
|
|
165
|
+
return comments.findLast((body) => body.includes(NOTED) || body.includes(REPORTED))?.includes(NOTED) ?? false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function clearedText(key: string, run: string): string {
|
|
169
|
+
return `The night run ${run} no longer reports \`${key}\`, so it closes this issue.`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function notedText(key: string, run: string): string {
|
|
173
|
+
return `The night run ${run} no longer reports \`${key}\`. This issue stays open because a mutation score can recover and drop again.\n\n${NOTED}\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const fileFindings = Effect.fn("fileFindings")(function* (findings: readonly Finding[], observed: Observed, run: string) {
|
|
143
177
|
const issues = yield* Issues;
|
|
144
|
-
yield* issues.ensureLabel;
|
|
145
|
-
const
|
|
178
|
+
if (findings.length > 0) yield* issues.ensureLabel;
|
|
179
|
+
const listed = yield* issues.open;
|
|
180
|
+
const present = new Set(findings.map(keyOf));
|
|
181
|
+
const open = new Map(listed.map((issue) => [issue.key, issue.number]));
|
|
146
182
|
const filed: Filed[] = [];
|
|
147
183
|
for (const finding of findings) {
|
|
148
184
|
const key = keyOf(finding);
|
|
@@ -152,10 +188,22 @@ export const fileFindings = Effect.fn("fileFindings")(function* (findings: reado
|
|
|
152
188
|
open.set(key, issue);
|
|
153
189
|
filed.push({ key, issue, action: "opened" });
|
|
154
190
|
} else {
|
|
155
|
-
yield* issues.comment(known,
|
|
191
|
+
yield* issues.comment(known, reportedText(finding, run));
|
|
156
192
|
filed.push({ key, issue: known, action: "commented on" });
|
|
157
193
|
}
|
|
158
194
|
}
|
|
195
|
+
for (const { number, key } of listed) {
|
|
196
|
+
if (noted(key, present, observed)) {
|
|
197
|
+
if (alreadyNoted(yield* issues.comments(number))) continue;
|
|
198
|
+
yield* issues.comment(number, notedText(key, run));
|
|
199
|
+
filed.push({ key, issue: number, action: "commented on" });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!cleared(key, present, observed)) continue;
|
|
203
|
+
yield* issues.comment(number, clearedText(key, run));
|
|
204
|
+
yield* issues.close(number);
|
|
205
|
+
filed.push({ key, issue: number, action: "closed" });
|
|
206
|
+
}
|
|
159
207
|
return filed;
|
|
160
208
|
});
|
|
161
209
|
|
|
@@ -218,13 +266,33 @@ const writeRecord = Effect.fn("writeRecord")(function* (record: NightRecord) {
|
|
|
218
266
|
yield* fs.writeFileString(RECORD, `${JSON.stringify(record, null, 2)}\n`);
|
|
219
267
|
});
|
|
220
268
|
|
|
221
|
-
|
|
222
|
-
|
|
269
|
+
type MutationReport = { readonly file: string; readonly files: Option.Option<ReadonlyMap<string, ReportFile>> };
|
|
270
|
+
|
|
271
|
+
// A mutation job that timed out or failed uploads no report, so a missing file is a finding rather than a failed read.
|
|
272
|
+
const mutationReport = Effect.fn("mutationReport")(function* (file: string) {
|
|
273
|
+
if (!(yield* (yield* FileSystem.FileSystem).exists(file).pipe(Effect.mapError(unreadable(file))))) return { file, files: Option.none() } satisfies MutationReport;
|
|
274
|
+
return { file, files: Option.some(yield* parseMutationReport(file, yield* readText(file))) } satisfies MutationReport;
|
|
223
275
|
});
|
|
224
276
|
|
|
277
|
+
function missingReports(reports: readonly MutationReport[]): readonly Finding[] {
|
|
278
|
+
return reports.flatMap(({ file, files }): readonly Finding[] => (Option.isNone(files) ? [{ kind: "mutation-report", file }] : []));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readReports(reports: readonly MutationReport[]): ReadonlySet<string> {
|
|
282
|
+
return new Set(reports.flatMap(({ file, files }) => (Option.isSome(files) ? [file] : [])));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function tonightScores(reports: readonly MutationReport[]): Option.Option<NightRecord["scores"]> {
|
|
286
|
+
const read = reports.flatMap(({ files }) => (Option.isSome(files) ? [...files.value] : []));
|
|
287
|
+
return reports.some(({ files }) => Option.isSome(files)) ? Option.some(scoresOf(new Map(read))) : Option.none();
|
|
288
|
+
}
|
|
289
|
+
|
|
225
290
|
type Filing = { readonly onMain: true; readonly filed: readonly Filed[] } | { readonly onMain: false; readonly ref: string };
|
|
226
291
|
|
|
227
|
-
|
|
292
|
+
// A night missing one report records nothing, so the next night still compares every file with the last night that scored it.
|
|
293
|
+
type Scored = { readonly files: number; readonly missing: number };
|
|
294
|
+
|
|
295
|
+
export function report(run: string, scored: Option.Option<Scored>, previous: Option.Option<PreviousNight>, findings: readonly Finding[], filing: Filing): string {
|
|
228
296
|
const against = Option.isNone(scored)
|
|
229
297
|
? "had no mutation report, so it compared no score"
|
|
230
298
|
: Option.isSome(previous)
|
|
@@ -234,23 +302,30 @@ export function report(run: string, scored: Option.Option<number>, previous: Opt
|
|
|
234
302
|
const listed = filing.onMain ? filing.filed.map((one) => ` ${one.action} #${one.issue} for ${one.key}`) : findings.map((finding) => ` found ${keyOf(finding)}`);
|
|
235
303
|
const recorded = !filing.onMain
|
|
236
304
|
? `${NAME}: the run is on ${filing.ref}, not ${DEFAULT_BRANCH}, so it filed no issue and recorded no score`
|
|
237
|
-
: Option.
|
|
238
|
-
? `${NAME}: recorded
|
|
239
|
-
:
|
|
305
|
+
: Option.isNone(scored)
|
|
306
|
+
? `${NAME}: recorded no score, since it had no mutation report`
|
|
307
|
+
: scored.value.missing === 0
|
|
308
|
+
? `${NAME}: recorded ${scored.value.files} file score(s) in ${RECORD}`
|
|
309
|
+
: `${NAME}: recorded no score, since ${scored.value.missing} mutation report(s) were missing`;
|
|
240
310
|
return [`${NAME}: the night run ${run} ${against}; ${verdict}`, ...listed, recorded].join("\n");
|
|
241
311
|
}
|
|
242
312
|
|
|
243
313
|
const nightRun = Effect.gen(function* () {
|
|
244
314
|
const options = yield* parseArgs(process.argv.slice(2));
|
|
245
315
|
const actions = yield* actionsRun;
|
|
246
|
-
const
|
|
316
|
+
const reports = yield* Effect.forEach(options.mutation, mutationReport);
|
|
317
|
+
const missing = missingReports(reports);
|
|
318
|
+
const scores = tonightScores(reports);
|
|
247
319
|
const tests: TestReport[] = yield* Effect.forEach(options.tests, testReport);
|
|
248
|
-
const
|
|
320
|
+
const log = Option.isSome(options.gates) ? Option.some(yield* readText(options.gates.value)) : Option.none<string>();
|
|
321
|
+
const gates: readonly Finding[] = Option.isSome(log) ? gatesOf(log.value) : [];
|
|
249
322
|
const previous = Option.isSome(scores) ? yield* previousNight(actions) : Option.none<PreviousNight>();
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
323
|
+
const observed: Observed = { tests: options.tests.length > 0, gates: Option.isSome(log) && gatesDecided(log.value), mutation: Option.isSome(previous) && missing.length === 0, mutationReports: readReports(reports) };
|
|
324
|
+
const findings = findingsOf(previous, { scores: Option.getOrElse(scores, () => ({})), tests }, [...missing, ...gates]);
|
|
325
|
+
const filing: Filing = actions.ref === `refs/heads/${DEFAULT_BRANCH}` ? { onMain: true, filed: yield* fileFindings(findings, observed, actions.run) } : { onMain: false, ref: actions.ref };
|
|
326
|
+
if (filing.onMain && Option.isSome(scores) && missing.length === 0) yield* writeRecord({ scores: scores.value });
|
|
327
|
+
const scored = Option.map(scores, (tonight): Scored => ({ files: Object.keys(tonight).length, missing: missing.length }));
|
|
328
|
+
yield* Console.log(report(actions.run, scored, previous, findings, filing));
|
|
254
329
|
return true;
|
|
255
330
|
});
|
|
256
331
|
|