@avi2dg/checks 0.40.0 → 0.41.1

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,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.41.1
6
+
7
+ Released 2026-10-14.
8
+
9
+ ### Fixes
10
+
11
+ - **delivery:** refuse workflow references to missing workflows or jobs in ci-wiring [#153](https://github.com/avi2d/checks/pull/153)
12
+
13
+ ## 0.41.0
14
+
15
+ Released 2026-10-12.
16
+
17
+ ### Features
18
+
19
+ - **testing:** keep mutation baselines and flake reports out of Actions artifact storage [#150](https://github.com/avi2d/checks/pull/150)
20
+
5
21
  ## 0.40.0
6
22
 
7
23
  Released 2026-10-11.
package/README.md CHANGED
@@ -183,7 +183,7 @@ These bins run on their own:
183
183
  - [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
184
184
  - [`checks-mutation`](docs/gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
185
185
  - [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) compares the mutants of two reports and lists each one that regresses.
186
- - [`checks-mutation-baseline`](docs/gates/checks-mutation-baseline.md) restores the newest mutation baseline from `main`, through a cache on a self-hosted runner, and deletes superseded baselines after a `main` upload.
186
+ - [`checks-mutation-baseline`](docs/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.
187
187
  - [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
188
188
  - [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
189
189
  - [`checks-release-notes`](docs/gates/checks-release-notes.md) writes one `CHANGELOG.md` section to a file for a GitHub release.
@@ -21,6 +21,14 @@ The check follows local reusable workflows but not remote reusable workflows.
21
21
  It refuses a step or job with `if: false` or `continue-on-error: true`.
22
22
  It also refuses a workflow that does not trigger on both `opened` and `synchronize` pull requests to the target branch.
23
23
  A path filter cannot cover every pull request and therefore cannot satisfy the check.
24
+ It refuses a reference to a workflow or job the repository does not hold.
25
+ Each literal `checks-release-pr` and `checks-release-tag` argument names a workflow file, so every one must match a file under `.github/workflows/`.
26
+ An argument built from a variable or an expression is known only when the job runs, so the check passes over it.
27
+ Each `on.workflow_run.workflows` entry names a workflow by its `name`, so every one must match a name in the repository.
28
+ A workflow without a `name` answers to its path from the repository root.
29
+ A local reusable `uses` must name a file under `.github/workflows/` that the repository holds.
30
+ Each `needs` entry names a job in the same workflow, so every one must match a job id in that file.
31
+ Each refusal names the file, the line and the missing workflow or job.
24
32
 
25
33
  ### Runners
26
34
 
@@ -57,7 +65,7 @@ It takes no arguments.
57
65
  | Code | Result |
58
66
  | --- | --- |
59
67
  | 0 | Every required command has a reachable step. |
60
- | 1 | A required command is missing or blocked, or a job runs on the wrong runner. |
68
+ | 1 | A required command is missing or blocked, a job runs on the wrong runner, or a workflow names a missing workflow or job. |
61
69
  | 2 | A workflow or `package.json` cannot be decoded. |
62
70
 
63
71
  ## Sample output
@@ -77,6 +85,13 @@ ci-wiring: 1 job(s) run on the wrong runner:
77
85
  .github/workflows/mutation-compare.yml job mutation-compare: a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; set runs-on: [self-hosted, Linux, X64, winbox]
78
86
  ```
79
87
 
88
+ A reference to a missing workflow produces a report like this:
89
+
90
+ ```
91
+ ci-wiring: 1 reference(s) name a missing workflow or job:
92
+ .github/workflows/daily-release.yml line 31: names missing workflow commitlint.yml
93
+ ```
94
+
80
95
  ## When it runs
81
96
 
82
97
  `checks-lint` runs it in every repository.
@@ -54,7 +54,7 @@ Only a schedule the repository writes runs it.
54
54
 
55
55
  ## Running it on a schedule
56
56
 
57
- A repository runs it on a schedule and keeps the record as an artifact:
57
+ A repository runs it on a schedule and keeps the record of a failed run as an artifact:
58
58
 
59
59
  ```yaml
60
60
  on:
@@ -70,12 +70,17 @@ jobs:
70
70
  - run: bun install --frozen-lockfile
71
71
  - run: bunx checks-flake --runs 10 --report flake-report.json
72
72
  - uses: actions/upload-artifact@v4
73
- if: always()
73
+ if: failure()
74
74
  with:
75
75
  name: flake-report
76
76
  path: flake-report.json
77
+ retention-days: 7
77
78
  ```
78
79
 
80
+ A passing run's job summary holds all its seeds, so a passing run uploads nothing.
81
+ In a private repository every upload counts against the account's Actions storage, and GitHub refuses each one once that storage is used up.
82
+ A run that only uploads on failure stays green through that refusal, and a failing run is red for its tests either way.
83
+
79
84
  A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on the job, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
80
85
 
81
86
  ## Related topics
@@ -4,14 +4,16 @@ audience: consumers
4
4
  ---
5
5
  # checks-mutation-baseline
6
6
 
7
- `checks-mutation-baseline` restores the newest mutation baseline that `main` published, and deletes superseded baselines after a `main` upload.
7
+ `checks-mutation-baseline` restores the newest mutation baseline that `main` published, and deletes superseded baselines after a `main` run.
8
8
  On a self-hosted runner it keeps each baseline it downloads, so the next job copies it instead.
9
+ There it also saves a run's baseline on the runner's disk, so the baseline never counts against the account's Actions storage.
9
10
 
10
11
  ## What it checks
11
12
 
12
13
  It reads the successful runs of `mutation.yml` on `main`.
13
14
  Without `--full` it takes the newest of the 20 newest of those runs that a pull request did not start.
14
- It restores that run's `mutation-baseline` artifact, and takes the run whether or not it holds one.
15
+ It restores the baseline that run saved on the runner's disk, as [The saved baselines](#the-saved-baselines) says.
16
+ Without one there, it restores that run's `mutation-baseline` artifact, and takes the run whether or not it holds one.
15
17
  It tries no older run, so a newest run without the artifact restores nothing.
16
18
  An artifact without `stryker-incremental.json` counts as no baseline.
17
19
  It copies `stryker-incremental.json` to the first destination, and `mutation/mutation.json` to the second destination when the artifact holds it.
@@ -25,6 +27,7 @@ It creates the directories each destination needs.
25
27
 
26
28
  With `--prune` it deletes the repository's superseded baselines instead of restoring one.
27
29
  It keeps the newest unexpired `mutation-baseline` among the runs the restore reads, and the newest unexpired `mutation-baseline-full` the restore takes first.
30
+ A run whose baseline the runner's disk holds stops that search, and every older `mutation-baseline` goes, since the restore reads that run's saved baseline first.
28
31
  It also keeps the unexpired baselines of the run it runs in, which `GITHUB_RUN_ID` names.
29
32
  The restore reads only successful runs, so it takes them once that run succeeds.
30
33
  A failed artifact lookup while it picks what to keep stops it before it deletes anything.
@@ -39,7 +42,8 @@ It deletes through `gh`, which needs a token that deletes the repository's Actio
39
42
  It reads the runs and their artifacts through `gh`, which needs a token that reads the repository's Actions, such as `GH_TOKEN: ${{ github.token }}` with `actions: read`.
40
43
  The `mutation-baseline` artifact holds `stryker-incremental.json` and `mutation/mutation.json`, which an upload of `reports/stryker-incremental.json` and `reports/mutation/mutation.json` writes.
41
44
  The `mutation-baseline-full` artifact holds `mutation.json` at its root, which an upload of `reports/mutation/mutation.json` alone writes.
42
- It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache, and `--prune` reads `GITHUB_RUN_ID`.
45
+ It reads `RUNNER_ENVIRONMENT` and `HOME` to place the runner cache, and `GITHUB_REPOSITORY_ID` to find the saved baselines.
46
+ `--save` and `--prune` read `GITHUB_RUN_ID`.
43
47
 
44
48
  ## The runner cache
45
49
 
@@ -58,24 +62,53 @@ A 100 MB baseline zip can unpack to about 500 MB, so a repository restoring both
58
62
  When `RUNNER_ENVIRONMENT` is `github-hosted`, it downloads into a temporary directory and writes no cache, since a hosted runner starts every job on a fresh machine.
59
63
  When `HOME` is unset it works the same way, and prints to stderr that the runner cache is disabled.
60
64
 
65
+ ## The saved baselines
66
+
67
+ `--save` writes the run's baseline to `$HOME/.cache/avi2dg-checks/mutation-baseline/<repository id>/saved/mutation-baseline/<run id>.json`.
68
+ The entry is one JSON file that holds `stryker-incremental.json` and, when given, `mutation/mutation.json`, keyed by those names.
69
+ It writes both files without each mutant's `statusReason`.
70
+ Stryker puts the test runner's failure message there, which no reader of a baseline uses, and which makes up most of a large report.
71
+ A report of about 280 MB comes out at about 5 MB.
72
+ The entry lands through a rename beside it, so a job sharing the runner sees it whole or not at all.
73
+ A re-run attempt keeps its run's id, so its entry replaces the one an earlier attempt saved.
74
+
75
+ The restore reads only the successful runs, so a run that saves and then fails leaves an entry no restore reads.
76
+ A re-run attempt that succeeds replaces it first.
77
+ A restore reads the entry in one open, so a job that removes or replaces it afterwards changes nothing the restore copies.
78
+ A restore from the disk removes the entries of runs older than the one it read.
79
+ It keeps the entries of newer runs, which may still be running.
80
+ A job that picked one of the removed entries and had not opened it yet finds none, and falls back to the artifact.
81
+
82
+ Every job that restores the baseline must run on the machine that saved it, as the same user.
83
+ A job on another machine finds no entry and falls back to the artifact, which the prune deletes once a saved baseline supersedes it.
84
+ Losing the disk costs the next `main` run a fresh baseline, since an incremental run with no state tests every mutant.
85
+
86
+ `--save` refuses on a GitHub-hosted runner, since its disk ends with the job.
87
+ There a repository uploads the baseline as an artifact.
88
+ It also refuses when `HOME`, `GITHUB_REPOSITORY_ID` or `GITHUB_RUN_ID` is unset.
89
+
61
90
  ## Arguments
62
91
 
63
92
  ```sh
64
93
  checks-mutation-baseline <incremental-dest> [mutation-json-dest]
65
94
  checks-mutation-baseline --full <mutation-json-dest>
95
+ checks-mutation-baseline --save <incremental-src> [mutation-json-src]
96
+ checks-mutation-baseline --slim <mutation-json-src> <dest>
66
97
  checks-mutation-baseline --prune [--dry-run]
67
98
  ```
68
99
 
69
100
  `--full` restores only the report of a run that started with no state.
70
- `--prune` deletes superseded baselines after an upload.
101
+ `--save` keeps this run's baseline on the runner's disk in place of an upload.
102
+ `--slim` writes a report without `statusReason` to `<dest>`, for a report that still goes up as an artifact.
103
+ `--prune` deletes superseded baselines after a save or an upload.
71
104
  `--dry-run` lists what `--prune` would delete, and deletes nothing.
72
105
 
73
106
  ## Exit codes
74
107
 
75
108
  | Code | When |
76
109
  | --- | --- |
77
- | 0 | it restored a baseline, or found none to restore |
78
- | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, or another job removed the entry before it was copied |
110
+ | 0 | it restored a baseline, found none to restore, saved the baseline or wrote the slim report |
111
+ | 2 | the arguments do not parse, `gh` cannot list the runs, the cache cannot be written, another job removed the entry before it was copied, a file to save or slim does not parse, or `--save` runs where it refuses |
79
112
 
80
113
  A failed artifact lookup or download prints the `gh` error and moves on, as a missing artifact does.
81
114
  With `--prune`, `0` means it deleted the superseded artifacts, found none, or could not delete some.
@@ -92,6 +125,12 @@ mutation-baseline: restored it from the runner's cache, mutation-baseline artifa
92
125
 
93
126
  A miss prints `downloaded it into the runner's cache` instead, and a hosted runner prints `downloaded it`.
94
127
 
128
+ ```
129
+ mutation-baseline: saved the mutation-baseline of run 38058558974 on the runner's disk
130
+ mutation-baseline: restored it from the runner's disk, mutation-baseline saved by run 38058558974
131
+ mutation-baseline: wrote full/mutation.json without statusReason, 4788924 bytes from 272879455
132
+ ```
133
+
95
134
  ```
96
135
  mutation-baseline: deleted artifacts 11542133901, 11553708473, 11632727585; kept mutation-baseline artifact 11670749656 from run 38055231501
97
136
  ```
@@ -110,16 +149,12 @@ A baseline job restores the state the previous `main` run left before an increme
110
149
  run: bunx checks-mutation-baseline reports/stryker-incremental.json
111
150
  ```
112
151
 
113
- A baseline job uploads the state the incremental run wrote, then on `main` prunes the superseded baselines:
152
+ A baseline job on a self-hosted runner saves the state the incremental run wrote, then on `main` prunes the superseded baselines:
114
153
 
115
154
  ```yaml
116
- - uses: actions/upload-artifact@v4
117
- with:
118
- name: mutation-baseline
119
- path: |
120
- reports/mutation/mutation.json
121
- reports/stryker-incremental.json
122
- retention-days: 7
155
+ - name: Save baseline state
156
+ if: github.ref == 'refs/heads/main'
157
+ run: bunx checks-mutation-baseline --save reports/stryker-incremental.json reports/mutation/mutation.json
123
158
  - name: Prune superseded baselines
124
159
  if: github.ref == 'refs/heads/main'
125
160
  env:
@@ -127,8 +162,23 @@ A baseline job uploads the state the incremental run wrote, then on `main` prune
127
162
  run: bunx checks-mutation-baseline --prune
128
163
  ```
129
164
 
130
- The incremental upload keeps 7 days of retention, since the restore reads only the newest `main` run.
131
- The full upload keeps 30 days, since full runs start weekly.
165
+ The saved baseline uses none of the account's Actions storage, which every private repository's artifacts share.
166
+ That storage accrues by the hour over the month, so an artifact deleted early still counts until the month ends.
167
+ A baseline job on a GitHub-hosted runner uploads the same two files as a `mutation-baseline` artifact with `retention-days: 7` instead, since the restore reads only the newest `main` run.
168
+
169
+ A full run uploads its report as the `mutation-baseline-full` artifact, which a pull request's scope step reads on any runner.
170
+ It slims the report first, so the artifact holds about 0.5 MB in place of about 100 MB:
171
+
172
+ ```yaml
173
+ - run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/full/mutation.json"
174
+ - uses: actions/upload-artifact@v4
175
+ with:
176
+ name: mutation-baseline-full
177
+ path: ${{ runner.temp }}/full/mutation.json
178
+ retention-days: 30
179
+ ```
180
+
181
+ The full upload keeps 30 days, so the newest full baseline outlives the gap between full runs.
132
182
  A pull request's scope step loses its coverage when no full baseline outlives the gap.
133
183
 
134
184
  A pull request's scope step restores the full report with `--full`:
@@ -97,13 +97,18 @@ jobs:
97
97
  bun-version-file: .bun-version
98
98
  - run: bun install --frozen-lockfile
99
99
  - run: bunx stryker run
100
+ - if: always()
101
+ run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/report/mutation.json"
100
102
  - uses: actions/upload-artifact@v4
101
103
  if: always()
102
104
  with:
103
105
  name: mutation-report
104
- path: reports/mutation/mutation.json
106
+ path: ${{ runner.temp }}/report/mutation.json
107
+ retention-days: 7
105
108
  ```
106
109
 
110
+ The upload holds the report without each mutant's `statusReason`, as [checks-mutation-baseline](checks-mutation-baseline.md#the-saved-baselines) says, since the full report of a large repository passes 100 MB as an artifact.
111
+
107
112
  The job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a repository sends its full sweeps.
108
113
  It never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the sweep waiting for `winbox`.
109
114
  [checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names it in `runs-on`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.40.0",
3
+ "version": "0.41.1",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,9 +47,11 @@
47
47
  "src/testing/mutation-guard-plugin.js",
48
48
  "src/testing/mutation-compare.ts",
49
49
  "src/testing/mutation-baseline.ts",
50
+ "src/testing/mutation-baseline-saved.ts",
50
51
  "src/testing/subsumed-tests.ts",
51
52
  "src/delivery/ci-wiring.ts",
52
53
  "src/delivery/shell-command.ts",
54
+ "src/delivery/workflow-references.ts",
53
55
  "src/delivery/mutation-job.ts",
54
56
  "src/delivery/secrets.ts",
55
57
  "src/delivery/gitleaks.ts",
@@ -5,6 +5,7 @@ import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
6
6
  import { readShellFiles, runsMutation, within, type Run } from "./mutation-job.ts";
7
7
  import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
8
+ import { findMissingWorkflows, formatMissingReport, isRecord, names, type Workflow } from "./workflow-references.ts";
8
9
 
9
10
  export type { Command };
10
11
 
@@ -20,11 +21,6 @@ export type Declaration = {
20
21
  readonly lintGates: readonly KitGate[];
21
22
  };
22
23
 
23
- export type Workflow = {
24
- readonly path: string;
25
- readonly document: unknown;
26
- };
27
-
28
24
  export type BlockedInvocation = {
29
25
  readonly location: string;
30
26
  readonly blocker: string;
@@ -84,16 +80,6 @@ const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
84
80
  const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
85
81
  const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
86
82
 
87
- function isRecord(value: unknown): value is Record<string, unknown> {
88
- return typeof value === "object" && value !== null && !Array.isArray(value);
89
- }
90
-
91
- function names(value: unknown): readonly string[] | undefined {
92
- if (typeof value === "string") return [value];
93
- if (!Array.isArray(value)) return undefined;
94
- return value.filter((entry): entry is string => typeof entry === "string");
95
- }
96
-
97
83
  function constant(value: unknown): boolean | undefined {
98
84
  if (typeof value === "boolean") return value;
99
85
  if (typeof value !== "string") return undefined;
@@ -333,7 +319,7 @@ export function formatRunnerReport(faults: readonly RunnerFault[]): string {
333
319
 
334
320
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
335
321
  Effect.try({
336
- try: () => ({ path, document: Bun.YAML.parse(text) }),
322
+ try: () => ({ path, document: Bun.YAML.parse(text), text }),
337
323
  catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
338
324
  });
339
325
 
@@ -386,11 +372,13 @@ const wiring = Effect.gen(function* () {
386
372
  const gaps = findGaps(declaration, workflows);
387
373
  const gapReport = formatReport(declaration, gaps);
388
374
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
375
+ const missing = findMissingWorkflows(workflows);
376
+ if (missing.length > 0) yield* Console.error(formatMissingReport(missing));
389
377
  const scripts = yield* readScripts(root);
390
378
  const runs = workflows.flatMap((workflow) => workflowJobs(workflow).flatMap(([, job]) => jobRuns(workflow, job)));
391
379
  const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts, files: yield* readShellFiles(root, scripts, runs) }, workflows);
392
380
  if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
393
- return gaps.length === 0 && faults.length === 0;
381
+ return gaps.length === 0 && faults.length === 0 && missing.length === 0;
394
382
  });
395
383
 
396
384
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -0,0 +1,198 @@
1
+ import { fromProgram, shellCommands, type Command } from "./shell-command.ts";
2
+
3
+ export type Workflow = {
4
+ readonly path: string;
5
+ readonly document: unknown;
6
+ readonly text: string;
7
+ };
8
+
9
+ export type MissingReference = {
10
+ readonly location: string;
11
+ readonly fault: string;
12
+ };
13
+
14
+ export function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+
18
+ export function names(value: unknown): readonly string[] | undefined {
19
+ if (typeof value === "string") return [value];
20
+ if (!Array.isArray(value)) return undefined;
21
+ return value.filter((entry): entry is string => typeof entry === "string");
22
+ }
23
+
24
+ type Key = string | number;
25
+
26
+ type Locator = (keys: readonly Key[], needle: string, fallback: string) => string;
27
+
28
+ // Lines from..to hold the node, and its child keys start at column.
29
+ type Node = { readonly from: number; readonly to: number; readonly column: number };
30
+
31
+ const WORKFLOWS = ".github/workflows";
32
+ const RELEASE_BINS = ["checks-release-pr", "checks-release-tag"];
33
+ const RELEASE_SOURCES = ["release-pr.ts", "release-tag.ts"];
34
+ const REUSABLE_PREFIX = "./.github/workflows/";
35
+ // GitHub substitutes an expression into the script before the shell reads it.
36
+ const EXPRESSION = /\$\{\{[\s\S]*?\}\}/g;
37
+
38
+ function isPathTo(word: string, files: readonly string[]): boolean {
39
+ return files.some((file) => word.endsWith(`/${file}`));
40
+ }
41
+
42
+ function releaseArguments(program: Command): readonly string[] {
43
+ const [head, target] = program;
44
+ if (head !== undefined && (RELEASE_BINS.includes(head) || isPathTo(head, RELEASE_BINS))) return program.slice(1);
45
+ if (head === "bun" && target !== undefined && (RELEASE_SOURCES.includes(target) || isPathTo(target, [...RELEASE_SOURCES, ...RELEASE_BINS]))) {
46
+ return program.slice(2);
47
+ }
48
+ return [];
49
+ }
50
+
51
+ function literalTargets(run: string): readonly string[] {
52
+ return shellCommands(run.replace(EXPRESSION, "$EXPRESSION"))
53
+ .flatMap((command) => releaseArguments(fromProgram(command)))
54
+ .filter((target) => !target.includes("$"));
55
+ }
56
+
57
+ function escapeRegExp(text: string): string {
58
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ }
60
+
61
+ function indentOf(line: string): number {
62
+ return line.length - line.trimStart().length;
63
+ }
64
+
65
+ function meaningful(line: string): boolean {
66
+ const text = line.trim();
67
+ return text !== "" && !text.startsWith("#");
68
+ }
69
+
70
+ function belongs(line: string, column: number): boolean {
71
+ return !meaningful(line) || indentOf(line) > column || (indentOf(line) === column && line.trim().startsWith("-"));
72
+ }
73
+
74
+ function firstBodyLine(lines: readonly string[], from: number, to: number): string | undefined {
75
+ return lines.slice(from + 1, to).find(meaningful);
76
+ }
77
+
78
+ function child(lines: readonly string[], node: Node, key: string): Node | undefined {
79
+ const pattern = new RegExp(`^(\\s*(?:-\\s+)?)["']?${escapeRegExp(key)}["']?\\s*:(\\s|$)`);
80
+ for (let at = node.from; at < node.to; at += 1) {
81
+ if (pattern.exec(lines[at] ?? "")?.[1]?.length !== node.column) continue;
82
+ let to = at + 1;
83
+ while (to < node.to && belongs(lines[to] ?? "", node.column)) to += 1;
84
+ const body = firstBodyLine(lines, at, to);
85
+ return { from: at, to, column: body === undefined ? -1 : indentOf(body) };
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function items(lines: readonly string[], node: Node): readonly Node[] {
91
+ const column = indentOf(firstBodyLine(lines, node.from, node.to) ?? "");
92
+ const starts: number[] = [];
93
+ for (let at = node.from + 1; at < node.to; at += 1) {
94
+ const line = lines[at] ?? "";
95
+ if (meaningful(line) && indentOf(line) === column && line.trim().startsWith("-")) starts.push(at);
96
+ }
97
+ return starts.map((from, index) => {
98
+ const to = starts[index + 1] ?? node.to;
99
+ const inline = /^\s*-\s+(?=\S)/.exec(lines[from] ?? "");
100
+ return { from, to, column: inline === null ? indentOf(firstBodyLine(lines, from, to) ?? "") : inline[0].length };
101
+ });
102
+ }
103
+
104
+ function nodeAt(lines: readonly string[], keys: readonly Key[]): Node | undefined {
105
+ return keys.reduce<Node | undefined>(
106
+ (node, key) => (node === undefined ? undefined : typeof key === "number" ? items(lines, node)[key] : child(lines, node, key)),
107
+ { from: 0, to: lines.length, column: 0 },
108
+ );
109
+ }
110
+
111
+ function locate(path: string, lines: readonly string[]): Locator {
112
+ const used = new Map<string, number>();
113
+ return (keys, needle, fallback) => {
114
+ const node = nodeAt(lines, keys);
115
+ if (node === undefined) return `${path} ${fallback}`;
116
+ const hits = lines.slice(node.from, node.to).flatMap((line, offset) => (line.includes(needle) ? [node.from + offset] : []));
117
+ const at = hits.find((line) => line > (used.get(needle) ?? -1)) ?? hits[0] ?? node.from;
118
+ used.set(needle, at);
119
+ return `${path} line ${at + 1}`;
120
+ };
121
+ }
122
+
123
+ function workflowTitle(document: unknown): string | undefined {
124
+ return isRecord(document) && typeof document["name"] === "string" ? document["name"] : undefined;
125
+ }
126
+
127
+ function missingNeeds(jobs: Readonly<Record<string, unknown>>, at: Locator): readonly MissingReference[] {
128
+ return Object.entries(jobs).flatMap(([id, job]) => {
129
+ if (!isRecord(job)) return [];
130
+ return (names(job["needs"]) ?? []).flatMap((need) =>
131
+ need in jobs ? [] : [{ location: at(["jobs", id, "needs"], need, `job ${id}`), fault: `job ${id} needs missing job ${need}` }],
132
+ );
133
+ });
134
+ }
135
+
136
+ function missingTarget(uses: string, paths: ReadonlySet<string>, at: Locator, keys: readonly Key[], fallback: string): MissingReference | undefined {
137
+ return uses.startsWith(REUSABLE_PREFIX) && !paths.has(uses.slice(2))
138
+ ? { location: at(keys, uses, fallback), fault: `names missing workflow ${uses.slice(2)}` }
139
+ : undefined;
140
+ }
141
+
142
+ function missingJobRefs(id: string, job: unknown, paths: ReadonlySet<string>, at: Locator): readonly MissingReference[] {
143
+ if (!isRecord(job)) return [];
144
+ const missing: MissingReference[] = [];
145
+ if (typeof job["uses"] === "string") {
146
+ const called = missingTarget(job["uses"], paths, at, ["jobs", id, "uses"], `job ${id}`);
147
+ if (called !== undefined) missing.push(called);
148
+ }
149
+ const steps = job["steps"];
150
+ if (!Array.isArray(steps)) return missing;
151
+ steps.forEach((step: unknown, index) => {
152
+ if (!isRecord(step)) return;
153
+ const fallback = `job ${id} step ${index + 1}`;
154
+ if (typeof step["uses"] === "string") {
155
+ const used = missingTarget(step["uses"], paths, at, ["jobs", id, "steps", index, "uses"], fallback);
156
+ if (used !== undefined) missing.push(used);
157
+ }
158
+ if (typeof step["run"] !== "string") return;
159
+ for (const target of literalTargets(step["run"])) {
160
+ if (!paths.has(`${WORKFLOWS}/${target}`)) {
161
+ missing.push({ location: at(["jobs", id, "steps", index, "run"], target, fallback), fault: `names missing workflow ${target}` });
162
+ }
163
+ }
164
+ });
165
+ return missing;
166
+ }
167
+
168
+ // GitHub names a workflow without a name by its path from the repository root.
169
+ export function findMissingWorkflows(workflows: readonly Workflow[]): readonly MissingReference[] {
170
+ const paths = new Set(workflows.map((workflow) => workflow.path));
171
+ const titles = new Set(workflows.map((workflow) => workflowTitle(workflow.document) ?? workflow.path));
172
+ return workflows.flatMap((workflow) => {
173
+ const missing: MissingReference[] = [];
174
+ const at = locate(workflow.path, workflow.text.split("\n"));
175
+ const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
176
+ if (isRecord(jobs)) {
177
+ missing.push(...missingNeeds(jobs, at));
178
+ for (const [id, job] of Object.entries(jobs)) missing.push(...missingJobRefs(id, job, paths, at));
179
+ }
180
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
181
+ const watched = isRecord(on) ? on["workflow_run"] : undefined;
182
+ if (isRecord(watched)) {
183
+ for (const entry of names(watched["workflows"]) ?? []) {
184
+ if (!titles.has(entry)) {
185
+ missing.push({ location: at(["on", "workflow_run", "workflows"], entry, "on workflow_run"), fault: `names missing workflow ${entry}` });
186
+ }
187
+ }
188
+ }
189
+ return missing;
190
+ });
191
+ }
192
+
193
+ export function formatMissingReport(faults: readonly MissingReference[]): string {
194
+ return [
195
+ `ci-wiring: ${faults.length} reference(s) name a missing workflow or job:`,
196
+ ...faults.map(({ location, fault }) => ` ${location}: ${fault}`),
197
+ ].join("\n");
198
+ }
@@ -0,0 +1,105 @@
1
+ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
2
+ import { cacheRoot } from "../dependencies/cache-root.ts";
3
+
4
+ export const LATEST_ARTIFACT = "mutation-baseline";
5
+ export const INCREMENTAL = "stryker-incremental.json";
6
+ export const REPORT = "mutation/mutation.json";
7
+ const SAVED = "saved";
8
+
9
+ class SaveFailure extends Schema.TaggedError<SaveFailure>()("SaveFailure", {
10
+ message: Schema.String,
11
+ }) {}
12
+
13
+ const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
14
+
15
+ const SavedFiles = Schema.Struct({ [INCREMENTAL]: Schema.Unknown, [REPORT]: Schema.optionalKey(Schema.Unknown) });
16
+ export type SavedFiles = typeof SavedFiles.Type;
17
+ const decodeSavedFiles = Schema.decodeUnknownEffect(Schema.fromJsonString(SavedFiles));
18
+
19
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> => typeof value === "object" && value !== null && !Array.isArray(value);
20
+
21
+ const withoutReason = (mutant: unknown): unknown => (isRecord(mutant) ? Object.fromEntries(Object.entries(mutant).filter(([key]) => key !== "statusReason")) : mutant);
22
+
23
+ function slimFile(file: unknown): unknown {
24
+ if (!isRecord(file)) return file;
25
+ const mutants = file["mutants"];
26
+ return Array.isArray(mutants) ? { ...file, mutants: mutants.map((mutant: unknown) => withoutReason(mutant)) } : file;
27
+ }
28
+
29
+ // statusReason holds the test runner's failure message for each mutant, which no reader of a baseline uses and which fills most of a large report.
30
+ export function withoutReasons(report: unknown): unknown {
31
+ if (!isRecord(report) || !isRecord(report["files"])) return report;
32
+ return { ...report, files: Object.fromEntries(Object.entries(report["files"]).map(([name, file]) => [name, slimFile(file)])) };
33
+ }
34
+
35
+ const slimOf = Effect.fn("slimOf")(function* (from: string) {
36
+ const report = yield* (yield* FileSystem.FileSystem).readFileString(from).pipe(
37
+ Effect.flatMap(decodeReport),
38
+ Effect.mapError((cause) => new SaveFailure({ message: `cannot read ${from}: ${cause.message}` })),
39
+ );
40
+ return withoutReasons(report);
41
+ });
42
+
43
+ export const slimmed = Effect.fn("slimmed")(function* (from: string, to: string) {
44
+ const fs = yield* FileSystem.FileSystem;
45
+ const report = yield* slimOf(from);
46
+ yield* fs.makeDirectory((yield* Path.Path).dirname(to), { recursive: true });
47
+ yield* fs.writeFileString(to, JSON.stringify(report));
48
+ });
49
+
50
+ const shelfOf = Effect.fn("shelfOf")(function* (root: string, repository: number) {
51
+ return (yield* Path.Path).join(root, "mutation-baseline", String(repository), SAVED, LATEST_ARTIFACT);
52
+ });
53
+
54
+ // GitHub sets GITHUB_REPOSITORY_ID on every job, so a run outside Actions has no saved baselines.
55
+ export const savedShelf = Effect.fn("savedShelf")(function* (root: Option.Option<string>) {
56
+ const repository = yield* Config.Int("GITHUB_REPOSITORY_ID").pipe(Config.option);
57
+ if (Option.isNone(root) || Option.isNone(repository)) return Option.none<string>();
58
+ return Option.some(yield* shelfOf(root.value, repository.value));
59
+ });
60
+
61
+ const savedEntry = Effect.fn("savedEntry")(function* (shelf: string, run: number) {
62
+ return (yield* Path.Path).join(shelf, `${run}.json`);
63
+ });
64
+
65
+ // One read opens the entry once, so a job that removes or replaces it afterwards leaves what this job read whole.
66
+ export const savedFiles = Effect.fn("savedFiles")(function* (shelf: string, run: number) {
67
+ const entry = yield* savedEntry(shelf, run);
68
+ const text = yield* (yield* FileSystem.FileSystem).readFileString(entry).pipe(
69
+ Effect.asSome,
70
+ Effect.catchReason("PlatformError", "NotFound", () => Effect.succeedNone),
71
+ );
72
+ if (Option.isNone(text)) return Option.none<SavedFiles>();
73
+ return Option.some(yield* decodeSavedFiles(text.value).pipe(Effect.mapError((cause) => new SaveFailure({ message: `cannot read ${entry}: ${cause.message}` }))));
74
+ });
75
+
76
+ // GitHub numbers runs in the order it creates them, so a lower id is an older run the restore never reads again.
77
+ export function outlived(entries: readonly string[], restoring: number): readonly string[] {
78
+ return entries.filter((entry) => {
79
+ const run = /^(\d+)\.json$/.exec(entry)?.[1];
80
+ return run !== undefined && Number(run) < restoring;
81
+ });
82
+ }
83
+
84
+ const required = (name: string) =>
85
+ Config.Int(name).pipe(Effect.mapError(() => new SaveFailure({ message: `${name} is unset, so --save runs only inside a GitHub Actions job` })));
86
+
87
+ // The entry is one file renamed into place, so a job sharing the runner reads one attempt's entry whole even while a re-run replaces it.
88
+ export const saveBaseline = Effect.fn("saveBaseline")(function* (incremental: string, report: Option.Option<string>) {
89
+ const fs = yield* FileSystem.FileSystem;
90
+ const path = yield* Path.Path;
91
+ const environment = yield* Config.String("RUNNER_ENVIRONMENT").pipe(Config.withDefault(""));
92
+ if (environment === "github-hosted") {
93
+ return yield* new SaveFailure({ message: "a GitHub-hosted runner discards its disk when the job ends, so upload the baseline as an artifact there" });
94
+ }
95
+ const root = yield* cacheRoot().pipe(Effect.mapError((cause) => new SaveFailure({ message: cause.message })));
96
+ const shelf = yield* shelfOf(root, yield* required("GITHUB_REPOSITORY_ID"));
97
+ const run = yield* required("GITHUB_RUN_ID");
98
+ const files: SavedFiles = { [INCREMENTAL]: yield* slimOf(incremental), ...(Option.isSome(report) ? { [REPORT]: yield* slimOf(report.value) } : {}) };
99
+ yield* fs.makeDirectory(shelf, { recursive: true });
100
+ const staged = path.join(yield* fs.makeTempDirectoryScoped({ directory: shelf, prefix: ".staging-" }), "entry.json");
101
+ yield* fs.writeFileString(staged, JSON.stringify(files));
102
+ yield* fs.rename(staged, yield* savedEntry(shelf, run));
103
+ yield* Console.log(`mutation-baseline: saved the ${LATEST_ARTIFACT} of run ${run} on the runner's disk`);
104
+ return true;
105
+ }, Effect.scoped);
@@ -4,20 +4,19 @@ import { ChildProcessSpawner } from "effect/process";
4
4
  import { collect } from "../core/git.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
6
  import { cacheRoot } from "../dependencies/cache-root.ts";
7
+ import { INCREMENTAL, LATEST_ARTIFACT, outlived, REPORT, saveBaseline, savedFiles, savedShelf, slimmed } from "./mutation-baseline-saved.ts";
7
8
 
8
9
  class BaselineFailure extends Schema.TaggedError<BaselineFailure>()("BaselineFailure", {
9
10
  message: Schema.String,
10
11
  }) {}
11
12
 
12
13
  const NAME = "checks-mutation-baseline";
13
- const USAGE = "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest> | --prune [--dry-run]";
14
+ const USAGE =
15
+ "usage: checks-mutation-baseline <incremental-dest> [mutation-json-dest] | --full <mutation-json-dest> | --save <incremental-src> [mutation-json-src] | --slim <mutation-json-src> <dest> | --prune [--dry-run]";
14
16
  const WORKFLOW = "mutation.yml";
15
17
  const BRANCH = "main";
16
- const INCREMENTAL = "stryker-incremental.json";
17
- const REPORT = "mutation/mutation.json";
18
18
  const REPORT_ALONE = "mutation.json";
19
19
  const KEPT_PER_ARTIFACT = 2;
20
- const LATEST_ARTIFACT = "mutation-baseline";
21
20
  const FULL_ARTIFACT = "mutation-baseline-full";
22
21
  const LATEST_LIMIT = 20;
23
22
  const FULL_LIMIT = 50;
@@ -63,12 +62,12 @@ export function newestFirst(runs: readonly Run[]): readonly number[] {
63
62
  return [...runs].sort((one, other) => other.createdAt.localeCompare(one.createdAt)).map((run) => run.databaseId);
64
63
  }
65
64
 
65
+ // All non-pull-request runs newest first: a newer full run holds no baseline, and the newest baseline must survive it.
66
66
  const latestCandidates = Effect.gen(function* () {
67
67
  const newest = (yield* listed(undefined, LATEST_LIMIT)).find((run) => run.event !== "pull_request");
68
68
  return newest === undefined ? [] : [newest.databaseId];
69
69
  });
70
70
 
71
- // All non-pull-request runs newest first: a newer full run holds no baseline, and the newest baseline must survive it.
72
71
  const latestRuns = listed(undefined, LATEST_LIMIT).pipe(Effect.map((runs) => newestFirst(runs.filter((run) => run.event !== "pull_request"))));
73
72
 
74
73
  // Each full event is listed on its own, because one shared window lets push runs crowd out every full run.
@@ -108,12 +107,22 @@ const missedDeletion = Effect.fn("missedDeletion")(function* (artifact: Artifact
108
107
  );
109
108
  });
110
109
 
110
+ // A run whose baseline the runner's disk holds supersedes every older artifact, since the restore reads that run's entry first.
111
+ const keptLatest = Effect.fn("keptLatest")(function* (runs: readonly number[], shelf: Option.Option<string>) {
112
+ for (const run of runs) {
113
+ if (Option.isSome(shelf) && Option.isSome(yield* savedFiles(shelf.value, run))) return { saved: Option.some(run), kept: Option.none<Kept>() };
114
+ const artifact = yield* artifactOf(run, LATEST_ARTIFACT);
115
+ if (Option.isSome(artifact)) return { saved: Option.none<number>(), kept: Option.some<Kept>({ run, id: artifact.value.id, name: LATEST_ARTIFACT }) };
116
+ }
117
+ return { saved: Option.none<number>(), kept: Option.none<Kept>() };
118
+ });
119
+
111
120
  const pruneBaselines = Effect.fn("pruneBaselines")(function* (dryRun: boolean) {
112
121
  const running = yield* Config.Int("GITHUB_RUN_ID").pipe(Config.option);
113
- const latest = yield* keptArtifact(yield* latestRuns, LATEST_ARTIFACT);
122
+ const latest = yield* keptLatest(yield* latestRuns, yield* savedShelf(yield* runnerCache));
114
123
  const full = yield* keptArtifact(yield* fullCandidates, FULL_ARTIFACT);
115
124
  const artifacts = yield* everyArtifact();
116
- const keeping = [...Option.toArray(latest), ...Option.toArray(full), ...uploadedBy(artifacts, running)];
125
+ const keeping = [...Option.toArray(latest.kept), ...Option.toArray(full), ...uploadedBy(artifacts, running)];
117
126
  const victims = superseded(artifacts, new Set(keeping.map((keep) => keep.id)));
118
127
  const removed: number[] = [];
119
128
  const missed: Missed[] = [];
@@ -124,7 +133,7 @@ const pruneBaselines = Effect.fn("pruneBaselines")(function* (dryRun: boolean) {
124
133
  else removed.push(victim.id);
125
134
  }
126
135
  }
127
- yield* Console.log(pruneReport(dryRun, dryRun ? victims.map((victim) => victim.id) : removed, missed, keeping));
136
+ yield* Console.log(pruneReport(dryRun, dryRun ? victims.map((victim) => victim.id) : removed, missed, keeping, latest.saved));
128
137
  return true;
129
138
  });
130
139
 
@@ -175,8 +184,12 @@ export function superseded(artifacts: readonly Artifact[], kept: ReadonlySet<num
175
184
  // A workflow command ends at a newline, so the error's own newlines and percent signs are escaped.
176
185
  const annotated = (text: string): string => text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
177
186
 
178
- export function pruneReport(dryRun: boolean, removed: readonly number[], missed: readonly Missed[], keeping: readonly Kept[]): string {
179
- const kept = keeping.length === 0 ? "no baseline artifacts" : keeping.map((keep) => `${keep.name} artifact ${keep.id} from run ${keep.run}`).join(", ");
187
+ export function pruneReport(dryRun: boolean, removed: readonly number[], missed: readonly Missed[], keeping: readonly Kept[], saved: Option.Option<number>): string {
188
+ const holdings = [
189
+ ...keeping.map((keep) => `${keep.name} artifact ${keep.id} from run ${keep.run}`),
190
+ ...Option.toArray(saved).map((run) => `the ${LATEST_ARTIFACT} run ${run} saved on the runner's disk`),
191
+ ];
192
+ const kept = holdings.length === 0 ? "no baseline artifacts" : holdings.join(", ");
180
193
  const deleted = removed.length === 0 ? "nothing to delete" : `deleted artifacts ${removed.join(", ")}`;
181
194
  if (dryRun) return `mutation-baseline: dry run, ${removed.length === 0 ? "nothing to delete" : `would delete artifacts ${removed.join(", ")}`}; would keep ${kept}`;
182
195
  if (missed.length === 0) return `mutation-baseline: ${deleted}; kept ${kept}`;
@@ -265,36 +278,80 @@ const runnerCache = Effect.gen(function* () {
265
278
  );
266
279
  });
267
280
 
281
+ const restoredFromSaved = Effect.fn("restoredFromSaved")(function* (shelf: string, run: number, request: Request) {
282
+ const fs = yield* FileSystem.FileSystem;
283
+ const path = yield* Path.Path;
284
+ const files = yield* savedFiles(shelf, run);
285
+ if (Option.isNone(files)) return false;
286
+ const stored = new Map(Object.entries(files.value));
287
+ for (const copy of [request.needed, ...request.optional]) {
288
+ const name = copy.from.find((from) => stored.has(from));
289
+ if (name === undefined) continue;
290
+ yield* fs.makeDirectory(path.dirname(copy.to), { recursive: true });
291
+ yield* fs.writeFileString(copy.to, JSON.stringify(stored.get(name)));
292
+ }
293
+ for (const older of outlived(yield* fs.readDirectory(shelf), run)) {
294
+ yield* fs.remove(path.join(shelf, older), { force: true });
295
+ }
296
+ yield* Console.log(`mutation-baseline: restored it from the runner's disk, ${LATEST_ARTIFACT} saved by run ${run}`);
297
+ return true;
298
+ });
299
+
300
+ const slimReport = Effect.fn("slimReport")(function* (from: string, to: string) {
301
+ const fs = yield* FileSystem.FileSystem;
302
+ const before = (yield* fs.stat(from)).size;
303
+ yield* slimmed(from, to);
304
+ const after = (yield* fs.stat(to)).size;
305
+ yield* Console.log(`mutation-baseline: wrote ${to} without statusReason, ${after} bytes from ${before}`);
306
+ return true;
307
+ });
308
+
268
309
  // An artifact that lacks `needed` counts as no baseline, and `optional` copies only what the artifact holds.
269
- type Request = { readonly full: boolean; readonly needed: Copy; readonly optional: readonly Copy[] };
310
+ type Request = { readonly kind: "restore"; readonly full: boolean; readonly needed: Copy; readonly optional: readonly Copy[] };
270
311
 
271
- type PruneRequest = { readonly prune: true; readonly dryRun: boolean };
312
+ type Command =
313
+ | Request
314
+ | { readonly kind: "prune"; readonly dryRun: boolean }
315
+ | { readonly kind: "save"; readonly incremental: string; readonly report: Option.Option<string> }
316
+ | { readonly kind: "slim"; readonly from: string; readonly to: string };
272
317
 
273
318
  const isDestination = (arg: string | undefined): arg is string => arg !== undefined && !arg.startsWith("-");
274
319
 
275
- function parsed(args: readonly string[]): Request | PruneRequest | undefined {
276
- if (args[0] === "--prune") {
277
- const [flag, ...extra] = args.slice(1);
278
- if ((flag === undefined || flag === "--dry-run") && extra.length === 0) return { prune: true, dryRun: flag !== undefined };
279
- return undefined;
280
- }
281
- if (args[0] === "--full") {
282
- const [report, ...extra] = args.slice(1);
283
- if (!isDestination(report) || extra.length > 0) return undefined;
284
- return { full: true, needed: { from: [REPORT_ALONE, REPORT], to: report }, optional: [] };
285
- }
286
- const [incremental, report, ...extra] = args;
320
+ function restoreRequest(incremental: string | undefined, report: string | undefined, extra: readonly string[]): Command | undefined {
287
321
  if (!isDestination(incremental) || extra.length > 0) return undefined;
288
- return { full: false, needed: { from: [INCREMENTAL], to: incremental }, optional: report === undefined ? [] : [{ from: [REPORT], to: report }] };
322
+ return { kind: "restore", full: false, needed: { from: [INCREMENTAL], to: incremental }, optional: report === undefined ? [] : [{ from: [REPORT], to: report }] };
289
323
  }
290
324
 
291
- const mutationBaseline = Effect.gen(function* () {
292
- const request = parsed(process.argv.slice(2));
293
- if (request === undefined) return yield* new Usage({ message: USAGE });
294
- if ("prune" in request) return yield* pruneBaselines(request.dryRun);
325
+ const FLAGS = new Map<string, (rest: readonly string[]) => Command | undefined>([
326
+ ["--prune", ([flag, ...extra]) => ((flag === undefined || flag === "--dry-run") && extra.length === 0 ? { kind: "prune", dryRun: flag !== undefined } : undefined)],
327
+ [
328
+ "--save",
329
+ ([incremental, report, ...extra]) =>
330
+ isDestination(incremental) && (report === undefined || isDestination(report)) && extra.length === 0
331
+ ? { kind: "save", incremental, report: Option.fromNullishOr(report) }
332
+ : undefined,
333
+ ],
334
+ ["--slim", ([from, to, ...extra]) => (isDestination(from) && isDestination(to) && extra.length === 0 ? { kind: "slim", from, to } : undefined)],
335
+ [
336
+ "--full",
337
+ ([report, ...extra]) =>
338
+ isDestination(report) && extra.length === 0 ? { kind: "restore", full: true, needed: { from: [REPORT_ALONE, REPORT], to: report }, optional: [] } : undefined,
339
+ ],
340
+ ]);
341
+
342
+ function parsed([first, ...rest]: readonly string[]): Command | undefined {
343
+ const flag = first === undefined ? undefined : FLAGS.get(first);
344
+ if (flag !== undefined) return flag(rest);
345
+ const [report, ...extra] = rest;
346
+ return restoreRequest(first, report, extra);
347
+ }
348
+
349
+ const restoreBaseline = Effect.fn("restoreBaseline")(function* (request: Request) {
295
350
  const name = request.full ? FULL_ARTIFACT : LATEST_ARTIFACT;
296
351
  const root = yield* runnerCache;
352
+ const shelf = request.full ? Option.none<string>() : yield* savedShelf(root);
297
353
  for (const run of yield* request.full ? fullCandidates : latestCandidates) {
354
+ if (Option.isSome(shelf) && (yield* restoredFromSaved(shelf.value, run, request))) return true;
298
355
  const artifact = yield* artifactOf(run, name).pipe(Effect.catch(warned(Option.none<Artifact>())));
299
356
  if (Option.isSome(artifact) && (yield* restoredFrom(run, artifact.value, root, request))) return true;
300
357
  }
@@ -302,4 +359,13 @@ const mutationBaseline = Effect.gen(function* () {
302
359
  return true;
303
360
  });
304
361
 
362
+ const mutationBaseline = Effect.gen(function* () {
363
+ const command = parsed(process.argv.slice(2));
364
+ if (command === undefined) return yield* new Usage({ message: USAGE });
365
+ if (command.kind === "prune") return yield* pruneBaselines(command.dryRun);
366
+ if (command.kind === "save") return yield* saveBaseline(command.incremental, command.report);
367
+ if (command.kind === "slim") return yield* slimReport(command.from, command.to);
368
+ return yield* restoreBaseline(command);
369
+ });
370
+
305
371
  if (import.meta.main) runMain(NAME, mutationBaseline);