@avi2dg/checks 0.9.0 → 0.10.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/README.md CHANGED
@@ -2,13 +2,16 @@
2
2
 
3
3
  Deterministic checks shared across my TypeScript repos. One package,
4
4
  `@avi2dg/checks`: the `checks-lint` entry point that runs every lint gate
5
- below over a range it resolves itself, the oxlint base config, the
6
- tsconfig fragment with the Effect language-service block, the shared
7
- commitlint config, the shared dependency-cruiser base, the test-layout
8
- check with its bunfig preset, the commit-identity check, the comment
9
- gate with its backtest, the oxlint suppressions ratchet, the Stryker
10
- mutation-testing preset with its no-regression comparator, the CI-wiring
11
- check, and the Effect error-channel plugin compiled to JavaScript.
5
+ below over a range it resolves itself, the `checks-test` entry point
6
+ that runs the suite and refuses an undeclared skip, the `checks-flake`
7
+ run that records the seeds a failing test fails with, the oxlint base
8
+ config, the tsconfig fragment with the Effect language-service block,
9
+ the shared commitlint config, the shared dependency-cruiser base, the
10
+ test-layout check with its bunfig preset, the commit-identity check, the
11
+ comment gate with its backtest, the oxlint suppressions ratchet, the
12
+ Stryker mutation-testing preset with its no-regression comparator, the
13
+ CI-wiring check, and the Effect error-channel plugin compiled to
14
+ JavaScript.
12
15
 
13
16
  Published as `@avi2dg/checks` on the public npm registry.
14
17
 
@@ -54,9 +57,12 @@ cp node_modules/@avi2dg/checks/bunfig.toml bunfig.toml
54
57
  ```json
55
58
  "lint": "oxlint --type-aware && checks-lint",
56
59
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
57
- "test": "bun test --randomize"
60
+ "test": "checks-test"
58
61
  ```
59
62
 
63
+ `checks-test` runs `bun test --randomize` and fails on a skip the
64
+ repository has not declared; see "Test entry point" below.
65
+
60
66
  `checks-lint` runs every kit gate a lint needs; see "Lint entry point"
61
67
  below. Three of them:
62
68
 
@@ -285,8 +291,9 @@ file and the path to move it to when it does not:
285
291
  them; `tests/fixtures/**` is data and is not parsed. Detection parses
286
292
  with swc and reads import specifiers and identifier use, so a test that
287
293
  only carries `"node:child_process"` as a string is not a violation.
288
- - `scripts.test` is exactly `bun test --randomize` and `scripts.lint` runs
289
- this check, itself or through `checks-lint` called by its bare bin name.
294
+ - `scripts.test` is exactly `checks-test`, which runs `bun test --randomize`
295
+ (see "Test entry point"), and `scripts.lint` runs this check, itself or
296
+ through `checks-lint` called by its bare bin name.
290
297
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the
291
298
  same value, and `[test].pathIgnorePatterns` is always
292
299
  `["**/tests/quarantine/**"]`: the check pins it itself, so this repo,
@@ -305,6 +312,109 @@ still run on demand:
305
312
  bun test --path-ignore-patterns='' tests/quarantine
306
313
  ```
307
314
 
315
+ ## Test entry point
316
+
317
+ `checks-test` runs the whole suite with `bun test --randomize`, passes
318
+ bun's output through, and then reads bun's JUnit report of the same run.
319
+ bun exits 0 with tests skipped, so a green run says nothing about the
320
+ tests that never ran. `checks-test` fails when a test was skipped, by
321
+ `test.skip`, `test.skipIf`, `test.if`, `describe.skip` or `test.todo`,
322
+ without a declaration in `package.json`:
323
+
324
+ ```json
325
+ "testSkips": [
326
+ {
327
+ "file": "tests/e2e/docker.test.ts",
328
+ "test": "images > builds the release image",
329
+ "reason": "the runner has no docker daemon",
330
+ "when": "ci"
331
+ }
332
+ ]
333
+ ```
334
+
335
+ `file` is the path bun reports, relative to the package root, and `test`
336
+ is the name bun's console prints: the describe blocks and the test name
337
+ joined by ` > `. `reason` is required. `when` is `ci` or `local` for a
338
+ test skipped only there, and a declaration without it holds in both;
339
+ `checks-test` counts a run as `ci` when `CI` is set true, as GitHub
340
+ Actions sets it. A declaration that holds for the run but matches no
341
+ skipped test fails a ci run too, so a fixed or renamed test takes its
342
+ declaration with it. A local run only warns about it, because whether a
343
+ test skips there can hang on the machine, such as a docker daemon being
344
+ up:
345
+
346
+ ```
347
+ checks-test: 1 skipped test(s) undeclared and 1 declaration(s) matching no skipped test in this ci run:
348
+ tests/pricing.test.ts:12 pricing > rounds half to even: skipped with no declaration; run it, or declare it in package.json testSkips with its reason
349
+ tests/e2e/docker.test.ts > images > builds the release image: declared, but no such test skipped; delete the declaration
350
+ ```
351
+
352
+ It exits 1 when a test failed or a skip is undeclared or, in a ci run,
353
+ a declaration stale, and 2 when `testSkips` does not parse or bun
354
+ passed without writing its report. It takes no arguments: a `-t`
355
+ filter reports every test it leaves out as skipped and a path filter
356
+ drops files a declaration names, so a narrowed run is plain
357
+ `bun test --randomize` with the arguments. Files under
358
+ `tests/quarantine/` are never run and so never reported; see "Test
359
+ layout".
360
+
361
+ ## Flake run
362
+
363
+ A green run proves nothing failed in that run, not that no test is
364
+ flaky. `checks-flake` runs the whole suite several times, each with its
365
+ own `--seed`, and records per failing test the seeds it failed with:
366
+
367
+ ```sh
368
+ checks-flake [--runs <count> | --seed <seed>...] [--report <file>]
369
+ ```
370
+
371
+ `--runs` defaults to 10 runs on random seeds, and `--seed`, given once
372
+ per run, replays chosen seeds, such as the ones a report recorded.
373
+ `bun test --randomize --seed=<seed>` puts the suite in the same order,
374
+ so a seed reproduces a failure that hangs on order. `--report` writes the
375
+ record as JSON, every run's seed and failing tests and every failing
376
+ test's seeds, and under GitHub Actions the summary below is appended to
377
+ the job summary:
378
+
379
+ ```
380
+ checks-flake: 3 of 10 run(s) failed, 1 test(s) failing in them
381
+
382
+ | Test | Failed | Seeds |
383
+ | --- | --- | --- |
384
+ | tests/cache.test.ts:6 reads the cache | 3 of 10 runs | 2170533150, 4046124386, 180394251 |
385
+
386
+ Reproduce a failing run with bun test --randomize --seed=<seed>.
387
+ ```
388
+
389
+ A run that fails with no failing test, such as a test file that throws
390
+ while loading, is listed with its seed on its own line. It exits 1 when
391
+ any run failed and 2 when bun passed without writing its report.
392
+
393
+ A consumer runs it on a schedule and keeps the record as an artifact:
394
+
395
+ ```yaml
396
+ on:
397
+ schedule:
398
+ - cron: "17 5 * * *"
399
+ workflow_dispatch:
400
+ jobs:
401
+ flake:
402
+ runs-on: ubuntu-latest
403
+ steps:
404
+ - uses: actions/checkout@v5
405
+ - uses: oven-sh/setup-bun@v2
406
+ - run: bun install --frozen-lockfile
407
+ - run: bunx checks-flake --runs 10 --report flake-report.json
408
+ - uses: actions/upload-artifact@v4
409
+ if: always()
410
+ with:
411
+ name: flake-report
412
+ path: flake-report.json
413
+ ```
414
+
415
+ and declares the step in `ciWiring.scheduled`, so `checks-ci-wiring`
416
+ fails once the schedule stops running it; see "CI wiring".
417
+
308
418
  ## Dependency rules
309
419
 
310
420
  `.dependency-cruiser.cjs` extends the shared base, which carries
@@ -555,9 +665,31 @@ ci-wiring: 1 of 8 gate(s) do not run on pull requests to main:
555
665
  .github/workflows/release.yml job publish step 7: .github/workflows/release.yml does not trigger on pull_request
556
666
  ```
557
667
 
558
- It exits 2 when `package.json` declares no gates, a gate is not one
559
- plain command, or a workflow does not parse. Whether a workflow is
560
- well formed is actionlint's question, not this one's.
668
+ It exits 2 when `package.json` declares no gates, `scheduled` is not an
669
+ array, a gate or scheduled command is not one plain command, or a
670
+ workflow does not parse. Whether a workflow is well formed is
671
+ actionlint's question, not this one's.
672
+
673
+ A command a schedule must run, such as the flake run, goes in
674
+ `"scheduled"` beside `"gates"`:
675
+
676
+ ```json
677
+ "ciWiring": {
678
+ "gates": ["bun run lint", "bun run typecheck", "bun run test"],
679
+ "scheduled": ["bunx checks-flake --runs 10 --report flake-report.json"]
680
+ }
681
+ ```
682
+
683
+ Each counts only as a step of the same plain shape in a workflow whose
684
+ `on` carries `schedule` with at least one `cron`, under the same
685
+ `if: false`, `continue-on-error: true` and `needs` rules as a gate. It
686
+ exits 1 naming each one no schedule runs:
687
+
688
+ ```
689
+ ci-wiring: 1 of 1 scheduled command(s) do not run on a schedule:
690
+ bunx checks-flake --runs 10 --report flake-report.json
691
+ .github/workflows/ci.yml job checks step 5: .github/workflows/ci.yml does not trigger on a schedule
692
+ ```
561
693
 
562
694
  ### Gate selection
563
695
 
@@ -741,6 +873,11 @@ The shared Stryker preset's `json` reporter writes
741
873
  that check, which is why a selection without it, or without another
742
874
  gate that applies everywhere, is refused as `checks-lint` reads it:
743
875
  nothing would check the selection otherwise.
876
+ - `checks-test` runs bun itself rather than reading a report some other
877
+ run left: a skip taken only on CI is visible only in CI's own run, and
878
+ an earlier run's report may be stale or narrowed. It reads the JUnit
879
+ report bun writes to a temporary directory, since bun has no other
880
+ per-test output meant for a program.
744
881
  - `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
745
882
  deleting the step that runs a check is the violation it catches, so the
746
883
  local `lint` is where it has to fail.
@@ -781,7 +918,8 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
781
918
  and tests before it publishes:
782
919
 
783
920
  ```sh
784
- git tag v0.9.0 && git push origin v0.9.0
921
+ tag="v$(bun -p 'require("./package.json").version')"
922
+ git tag "$tag" && git push origin "$tag"
785
923
  ```
786
924
 
787
925
  The `release` workflow publishes the tagged version through npm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,9 @@
21
21
  "scripts/gates.ts",
22
22
  "scripts/lint.ts",
23
23
  "scripts/test-layout.ts",
24
+ "scripts/test.ts",
25
+ "scripts/test-report.ts",
26
+ "scripts/flake.ts",
24
27
  "scripts/commit-identity.ts",
25
28
  "scripts/mutation-compare.ts",
26
29
  "scripts/ci-wiring.ts",
@@ -41,6 +44,8 @@
41
44
  "./scripts/lint-coverage.sh": "./scripts/lint-coverage.sh",
42
45
  "./scripts/lint.ts": "./scripts/lint.ts",
43
46
  "./scripts/test-layout.ts": "./scripts/test-layout.ts",
47
+ "./scripts/test.ts": "./scripts/test.ts",
48
+ "./scripts/flake.ts": "./scripts/flake.ts",
44
49
  "./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
45
50
  "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
46
51
  "./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
@@ -58,6 +63,8 @@
58
63
  "checks-lint": "scripts/lint.ts",
59
64
  "checks-lint-coverage": "scripts/lint-coverage.sh",
60
65
  "checks-test-layout": "scripts/test-layout.ts",
66
+ "checks-test": "scripts/test.ts",
67
+ "checks-flake": "scripts/flake.ts",
61
68
  "checks-commit-identity": "scripts/commit-identity.ts",
62
69
  "checks-mutation-compare": "scripts/mutation-compare.ts",
63
70
  "checks-ci-wiring": "scripts/ci-wiring.ts",
@@ -69,7 +76,7 @@
69
76
  "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
70
77
  "lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
71
78
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
72
- "test": "bun test --randomize"
79
+ "test": "bun scripts/test.ts"
73
80
  },
74
81
  "ciWiring": {
75
82
  "gates": [
@@ -79,6 +86,9 @@
79
86
  "bun run typecheck",
80
87
  "bun run test",
81
88
  "./node_modules/.bin/commitlint"
89
+ ],
90
+ "scheduled": [
91
+ "bun scripts/flake.ts --runs 10 --report flake-report.json"
82
92
  ]
83
93
  },
84
94
  "peerDependencies": {
@@ -21,6 +21,7 @@ export type Gate = {
21
21
 
22
22
  export type Declaration = {
23
23
  readonly gates: readonly Gate[];
24
+ readonly scheduled: readonly Gate[];
24
25
  readonly defaultBranch: string;
25
26
  readonly lintGates: readonly KitGate[];
26
27
  };
@@ -53,6 +54,8 @@ type RunStep = {
53
54
  readonly script: string;
54
55
  };
55
56
 
57
+ type Trigger = (workflow: Workflow) => string | undefined;
58
+
56
59
  export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
57
60
  message: Schema.String,
58
61
  }) {}
@@ -161,7 +164,7 @@ function selects(patterns: readonly string[], branch: string): boolean {
161
164
  return selected;
162
165
  }
163
166
 
164
- function triggerBlocker(workflow: Workflow, branch: string): string | undefined {
167
+ const pullRequestTrigger = (branch: string): Trigger => (workflow) => {
165
168
  const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
166
169
  const events = isRecord(on) ? Object.keys(on) : (names(on) ?? []);
167
170
  if (!events.includes("pull_request")) return `${workflow.path} does not trigger on pull_request`;
@@ -183,7 +186,14 @@ function triggerBlocker(workflow: Workflow, branch: string): string | undefined
183
186
  const missing = types === undefined ? [] : GATING_TYPES.filter((type) => !types.includes(type));
184
187
  if (missing.length > 0) return `${workflow.path} limits pull_request to types without ${missing.join(", ")}`;
185
188
  return undefined;
186
- }
189
+ };
190
+
191
+ const scheduleTrigger: Trigger = (workflow) => {
192
+ const on = isRecord(workflow.document) ? workflow.document["on"] : undefined;
193
+ const schedule = isRecord(on) ? on["schedule"] : undefined;
194
+ const crons = Array.isArray(schedule) ? schedule.filter((entry) => isRecord(entry) && typeof entry["cron"] === "string") : [];
195
+ return crons.length > 0 ? undefined : `${workflow.path} does not trigger on a schedule`;
196
+ };
187
197
 
188
198
  // GitHub prefixes any other if: with success(), so only a status function overrides a needed job's skip.
189
199
  function skippedBy(jobs: Readonly<Record<string, unknown>>, id: string, seen: readonly string[]): string | undefined {
@@ -202,7 +212,7 @@ function localCall(uses: unknown): string | undefined {
202
212
  return typeof uses === "string" && uses.startsWith("./") ? uses.slice(2) : undefined;
203
213
  }
204
214
 
205
- function runSteps(workflows: readonly Workflow[], branch: string): readonly RunStep[] {
215
+ function runSteps(workflows: readonly Workflow[], trigger: Trigger): readonly RunStep[] {
206
216
  const documents = new Map(workflows.map((workflow) => [workflow.path, workflow.document]));
207
217
  const steps: RunStep[] = [];
208
218
 
@@ -240,7 +250,7 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
240
250
  };
241
251
 
242
252
  for (const workflow of workflows) {
243
- visit(workflow.document, workflow.path, triggerBlocker(workflow, branch), [workflow.path]);
253
+ visit(workflow.document, workflow.path, trigger(workflow), [workflow.path]);
244
254
  }
245
255
  return steps;
246
256
  }
@@ -250,10 +260,9 @@ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Comman
250
260
  return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
251
261
  }
252
262
 
253
- export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
254
- const steps = runSteps(workflows, declaration.defaultBranch);
255
- return declaration.gates.flatMap((gate) => {
256
- const entryPoint = entryPointCommand(gate.words, declaration.lintGates);
263
+ function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
264
+ return gates.flatMap((gate) => {
265
+ const entryPoint = entryPointCommand(gate.words, lintGates);
257
266
  const commands = [
258
267
  { command: gate.command, words: gate.words },
259
268
  ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
@@ -274,10 +283,16 @@ export function findGaps(declaration: Declaration, workflows: readonly Workflow[
274
283
  });
275
284
  }
276
285
 
277
- export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
278
- const target = `pull requests to ${declaration.defaultBranch}`;
279
- if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
280
- const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
286
+ export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
287
+ return gapsAmong(declaration.gates, runSteps(workflows, pullRequestTrigger(declaration.defaultBranch)), declaration.lintGates);
288
+ }
289
+
290
+ export function findScheduledGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
291
+ return gapsAmong(declaration.scheduled, runSteps(workflows, scheduleTrigger), declaration.lintGates);
292
+ }
293
+
294
+ function gapLines(gaps: readonly Gap[]): readonly string[] {
295
+ const lines: string[] = [];
281
296
  for (const gap of gaps) {
282
297
  lines.push(` ${gap.gate}`);
283
298
  if (gap.blocked.length === 0) {
@@ -285,11 +300,38 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
285
300
  }
286
301
  for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
287
302
  }
288
- return lines.join("\n");
303
+ return lines;
289
304
  }
290
305
 
291
306
  const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
292
307
 
308
+ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
309
+ const target = `pull requests to ${declaration.defaultBranch}`;
310
+ if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
311
+ return [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`, ...gapLines(gaps)].join("\n");
312
+ }
313
+
314
+ export function formatScheduledReport(declaration: Declaration, gaps: readonly Gap[]): string {
315
+ const total = declaration.scheduled.length;
316
+ if (gaps.length === 0) return `ci-wiring: ${total} scheduled command(s) run on a schedule`;
317
+ return [`ci-wiring: ${gaps.length} of ${total} scheduled command(s) do not run on a schedule:`, ...gapLines(gaps)].join("\n");
318
+ }
319
+
320
+ const plainCommands = Effect.fnUntraced(function* (
321
+ listed: readonly unknown[],
322
+ subject: string,
323
+ ): Effect.fn.Return<readonly Gate[], WiringError> {
324
+ const gates: Gate[] = [];
325
+ for (const command of listed) {
326
+ const words = typeof command === "string" ? plainCommand(command) : undefined;
327
+ if (typeof command !== "string" || words === undefined) {
328
+ return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
329
+ }
330
+ gates.push({ command, words });
331
+ }
332
+ return gates;
333
+ });
334
+
293
335
  export const parseDeclaration = Effect.fnUntraced(function* (
294
336
  manifest: unknown,
295
337
  source: string,
@@ -299,22 +341,18 @@ export const parseDeclaration = Effect.fnUntraced(function* (
299
341
  if (!Array.isArray(listed) || listed.length === 0) {
300
342
  return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
301
343
  }
302
- const commands: readonly unknown[] = listed;
303
- const gates: Gate[] = [];
304
- for (const command of commands) {
305
- const words = typeof command === "string" ? plainCommand(command) : undefined;
306
- if (typeof command !== "string" || words === undefined) {
307
- return yield* new WiringError({
308
- message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
309
- });
310
- }
311
- gates.push({ command, words });
344
+ const gates = yield* plainCommands(listed, `${source} ciWiring gate`);
345
+ const scheduledListed = configured["scheduled"] ?? [];
346
+ if (!Array.isArray(scheduledListed)) {
347
+ return yield* new WiringError({ message: `${source} ciWiring.scheduled is not an array of commands` });
312
348
  }
349
+ const scheduled = yield* plainCommands(scheduledListed, `${source} ciWiring scheduled command`);
313
350
  const { ciWiring } = yield* decodeWiring(manifest).pipe(
314
351
  Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
315
352
  );
316
353
  return {
317
354
  gates,
355
+ scheduled,
318
356
  defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
319
357
  lintGates: selectedGates(ciWiring?.lintGates),
320
358
  };
@@ -389,7 +427,8 @@ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string)
389
427
  const wiring = Effect.gen(function* () {
390
428
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
391
429
  const declaration = yield* readDeclaration(root);
392
- const gaps = findGaps(declaration, yield* readWorkflows(root));
430
+ const workflows = yield* readWorkflows(root);
431
+ const gaps = findGaps(declaration, workflows);
393
432
  const omissions = yield* findOmissions(root, declaration.lintGates);
394
433
  const gapReport = formatReport(declaration, gaps);
395
434
  yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
@@ -397,7 +436,11 @@ const wiring = Effect.gen(function* () {
397
436
  if (omissionReport !== undefined) {
398
437
  yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
399
438
  }
400
- return gaps.length === 0 && omissions.length === 0;
439
+ if (declaration.scheduled.length === 0) return gaps.length === 0 && omissions.length === 0;
440
+ const scheduledGaps = findScheduledGaps(declaration, workflows);
441
+ const scheduledReport = formatScheduledReport(declaration, scheduledGaps);
442
+ yield* scheduledGaps.length > 0 ? Console.error(scheduledReport) : Console.log(scheduledReport);
443
+ return gaps.length === 0 && omissions.length === 0 && scheduledGaps.length === 0;
401
444
  });
402
445
 
403
446
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { runMain, Usage } from "./main.ts";
5
+ import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
+
7
+ export type Run = {
8
+ readonly seed: number;
9
+ readonly passed: boolean;
10
+ readonly tests: number;
11
+ readonly failed: readonly TestResult[];
12
+ };
13
+
14
+ export type Failure = {
15
+ readonly file: string;
16
+ readonly test: string;
17
+ readonly line: number;
18
+ readonly seeds: readonly number[];
19
+ };
20
+
21
+ export type RunRecord = Omit<Run, "failed"> & { readonly failed: readonly string[] };
22
+
23
+ export type FlakeRecord = {
24
+ readonly runs: readonly RunRecord[];
25
+ readonly failures: readonly Failure[];
26
+ readonly outsideTests: readonly number[];
27
+ };
28
+
29
+ type Plan = { readonly seeds: readonly number[] } | { readonly runs: number };
30
+
31
+ const NAME = "checks-flake";
32
+ const USAGE = `usage: ${NAME} [--runs <count> | --seed <seed>...] [--report <file>]`;
33
+ const DEFAULT_RUNS = 10;
34
+ const MAX_SEED = 2 ** 32 - 1;
35
+
36
+ const wholeBetween = (minimum: number, maximum: number) =>
37
+ Schema.decodeUnknownEffect(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum, maximum })));
38
+ const decodeRuns = wholeBetween(1, 1000);
39
+ const decodeSeed = wholeBetween(0, MAX_SEED);
40
+
41
+ const parseArgs = Effect.fnUntraced(function* (args: readonly string[]) {
42
+ const usage = (reason?: string) => new Usage({ message: reason === undefined ? USAGE : `${reason}; ${USAGE}` });
43
+ let runs: string | undefined;
44
+ let report: string | undefined;
45
+ const seeds: string[] = [];
46
+ for (let index = 0; index < args.length; index += 2) {
47
+ const [flag, value] = [args[index], args[index + 1]];
48
+ if (value === undefined) return yield* usage();
49
+ if (flag === "--runs" && runs === undefined) runs = value;
50
+ else if (flag === "--seed") seeds.push(value);
51
+ else if (flag === "--report" && report === undefined) report = value;
52
+ else return yield* usage();
53
+ }
54
+ if (runs !== undefined && seeds.length > 0) return yield* usage();
55
+ const invalid = (flag: string) => (cause: { readonly message: string }) => usage(`${flag}: ${cause.message}`);
56
+ const plan: Plan =
57
+ seeds.length > 0
58
+ ? { seeds: yield* Effect.forEach(seeds, (seed) => decodeSeed(seed).pipe(Effect.mapError(invalid("--seed")))) }
59
+ : { runs: runs === undefined ? DEFAULT_RUNS : yield* decodeRuns(runs).pipe(Effect.mapError(invalid("--runs"))) };
60
+ return { plan, report };
61
+ });
62
+
63
+ const seedsOf = (plan: Plan) =>
64
+ "seeds" in plan ? Effect.succeed(plan.seeds) : Effect.replicateEffect(Random.nextIntBetween(0, MAX_SEED), plan.runs);
65
+
66
+ const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string) {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69
+ const args = ["test", "--randomize", `--seed=${seed}`, ...reporterArgs(outfile)];
70
+ const exitCode = yield* spawner.exitCode(
71
+ ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }),
72
+ );
73
+ const passed = exitCode === ChildProcessSpawner.ExitCode(0);
74
+ if (!(yield* fs.exists(outfile))) {
75
+ if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
76
+ return { seed, passed, tests: 0, failed: [] } satisfies Run;
77
+ }
78
+ const results = yield* parseReport(yield* fs.readFileString(outfile));
79
+ return { seed, passed, tests: results.length, failed: results.filter((result) => result.outcome === "failed") } satisfies Run;
80
+ });
81
+
82
+ function outcomeOf(run: Run): string {
83
+ if (run.passed) return "passed";
84
+ return run.failed.length === 0 ? "failed outside any test" : `failed, ${run.failed.length} test(s) failing`;
85
+ }
86
+
87
+ function nameOf(result: TestResult): string {
88
+ return `${result.file}${NAME_SEPARATOR}${result.name}`;
89
+ }
90
+
91
+ export function recordOf(runs: readonly Run[]): FlakeRecord {
92
+ const failures = new Map<string, { file: string; test: string; line: number; seeds: number[] }>();
93
+ for (const run of runs) {
94
+ const failing = new Map(run.failed.map((result) => [nameOf(result), result]));
95
+ for (const [name, result] of failing) {
96
+ const failure = failures.get(name) ?? { file: result.file, test: result.name, line: result.line, seeds: [] };
97
+ failure.seeds.push(run.seed);
98
+ failures.set(name, failure);
99
+ }
100
+ }
101
+ return {
102
+ runs: runs.map(({ seed, passed, tests, failed }) => ({ seed, passed, tests, failed: failed.map(nameOf) })),
103
+ failures: [...failures.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
104
+ outsideTests: runs.filter((run) => !run.passed && run.failed.length === 0).map((run) => run.seed),
105
+ };
106
+ }
107
+
108
+ function cell(text: string): string {
109
+ return text.replaceAll("|", "\\|");
110
+ }
111
+
112
+ export function summary(record: FlakeRecord): string {
113
+ const total = record.runs.length;
114
+ const failedRuns = record.runs.filter((run) => !run.passed).length;
115
+ if (failedRuns === 0) return `${NAME}: ${total} run(s) passed, with seeds ${record.runs.map((run) => run.seed).join(", ")}`;
116
+ const lines = [`${NAME}: ${failedRuns} of ${total} run(s) failed, ${record.failures.length} test(s) failing in them`];
117
+ if (record.failures.length > 0) {
118
+ lines.push("", "| Test | Failed | Seeds |", "| --- | --- | --- |");
119
+ for (const failure of record.failures) {
120
+ const place = cell(`${failure.file}:${failure.line} ${failure.test}`);
121
+ lines.push(`| ${place} | ${failure.seeds.length} of ${total} runs | ${failure.seeds.join(", ")} |`);
122
+ }
123
+ }
124
+ if (record.outsideTests.length > 0) {
125
+ lines.push("", `${record.outsideTests.length} run(s) failed outside any test, with seeds ${record.outsideTests.join(", ")}`);
126
+ }
127
+ lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
128
+ return lines.join("\n");
129
+ }
130
+
131
+ const flake = Effect.gen(function* () {
132
+ const fs = yield* FileSystem.FileSystem;
133
+ const path = yield* Path.Path;
134
+ const { plan, report } = yield* parseArgs(process.argv.slice(2));
135
+ const seeds = yield* seedsOf(plan);
136
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-flake-" });
137
+
138
+ const runs = yield* Effect.forEach(seeds, (seed, index) =>
139
+ runOnce(seed, path.join(dir, `${index}.xml`)).pipe(
140
+ Effect.tap((run) => Console.log(`${NAME}: run ${index + 1} of ${seeds.length}, seed ${seed}: ${outcomeOf(run)}`)),
141
+ ),
142
+ );
143
+ const record = recordOf(runs);
144
+ const text = summary(record);
145
+ yield* Console.log(text);
146
+
147
+ if (report !== undefined) yield* fs.writeFileString(report, `${JSON.stringify(record, null, 2)}\n`);
148
+ const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
149
+ if (Option.isSome(stepSummary)) yield* fs.writeFileString(stepSummary.value, `${text}\n`, { flag: "a" });
150
+ return runs.every((run) => run.passed);
151
+ }).pipe(Effect.scoped);
152
+
153
+ if (import.meta.main) runMain(NAME, flake);
package/scripts/gates.ts CHANGED
@@ -19,6 +19,8 @@ export type KitGate = Program & {
19
19
 
20
20
  export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
21
21
 
22
+ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts" };
23
+
22
24
  export const DEFAULT_BRANCH = "main";
23
25
 
24
26
  const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
@@ -3,7 +3,7 @@ import { parse } from "@swc/core";
3
3
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
4
4
  import { git } from "./git.ts";
5
5
  import { runMain } from "./main.ts";
6
- import { ENTRY_POINT } from "./gates.ts";
6
+ import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
7
7
 
8
8
  export type Violation = {
9
9
  readonly file: string;
@@ -39,13 +39,13 @@ const BANNED_BUN_NAMES: readonly string[] = [
39
39
  ];
40
40
  const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
41
41
 
42
- export const REQUIRED_TEST_SCRIPT = "bun test --randomize";
43
42
  const REQUIRED_TEST_TABLE: Record<string, unknown> = {
44
43
  pathIgnorePatterns: ["**/tests/quarantine/**"],
45
44
  };
46
45
  export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
47
46
  export const LAYOUT_CHECK_BIN = "checks-test-layout";
48
47
  const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
48
+ const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun scripts/${TEST_ENTRY_POINT.script}`];
49
49
 
50
50
  export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
51
51
  message: Schema.String,
@@ -226,11 +226,11 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
226
226
  const test = isRecord(scripts) ? scripts["test"] : undefined;
227
227
  const lint = isRecord(scripts) ? scripts["lint"] : undefined;
228
228
  const violations: Violation[] = [];
229
- if (test !== REQUIRED_TEST_SCRIPT) {
229
+ if (typeof test !== "string" || !TEST_SCRIPTS.includes(test)) {
230
230
  violations.push({
231
231
  file,
232
232
  line: undefined,
233
- message: `scripts.test must be exactly "${REQUIRED_TEST_SCRIPT}", found ${JSON.stringify(test ?? null)}`,
233
+ message: `scripts.test must be exactly "${TEST_ENTRY_POINT.bin}", which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
234
234
  });
235
235
  }
236
236
  if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
@@ -0,0 +1,115 @@
1
+ import { Effect, Schema } from "effect";
2
+
3
+ export type Outcome = "passed" | "failed" | "skipped" | "todo";
4
+
5
+ export type TestResult = {
6
+ readonly file: string;
7
+ readonly name: string;
8
+ readonly line: number;
9
+ readonly outcome: Outcome;
10
+ };
11
+
12
+ export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
13
+ message: Schema.String,
14
+ }) {}
15
+
16
+ type Element = {
17
+ readonly tag: string;
18
+ readonly attributes: Readonly<Record<string, string>>;
19
+ readonly children: Element[];
20
+ };
21
+
22
+ // bun's console prints a test inside describe blocks as "outer > inner > name".
23
+ export const NAME_SEPARATOR = " > ";
24
+
25
+ export function reporterArgs(outfile: string): readonly string[] {
26
+ return ["--reporter=junit", `--reporter-outfile=${outfile}`];
27
+ }
28
+
29
+ const MARKUP =
30
+ /<(?:\?[\s\S]*?\?>|!--[\s\S]*?-->|!\[CDATA\[[\s\S]*?\]\]>|(\/?)([A-Za-z_][\w.:-]*)((?:\s+[A-Za-z_][\w.:-]*\s*=\s*"[^"<]*")*)\s*(\/?)>)/g;
31
+ const ATTRIBUTE = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"<]*)"/g;
32
+ const ENTITY = /&(?:(amp|lt|gt|quot|apos)|#(\d+)|#x([0-9A-Fa-f]+));/g;
33
+ const NAMED_ENTITIES: Readonly<Record<string, string>> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
34
+
35
+ const decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.String }));
36
+ const decodeCase = Schema.decodeUnknownEffect(
37
+ Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
38
+ );
39
+
40
+ function unescape(text: string): string {
41
+ return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
42
+ if (named !== undefined) return NAMED_ENTITIES[named] ?? "";
43
+ return String.fromCodePoint(decimal === undefined ? Number.parseInt(hex ?? "", 16) : Number.parseInt(decimal, 10));
44
+ });
45
+ }
46
+
47
+ function attributesOf(source: string): Readonly<Record<string, string>> {
48
+ const attributes: Record<string, string> = {};
49
+ for (const [, name, value] of source.matchAll(ATTRIBUTE)) {
50
+ if (name !== undefined && value !== undefined) attributes[name] = unescape(value);
51
+ }
52
+ return attributes;
53
+ }
54
+
55
+ const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
56
+ const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
57
+ const document: Element = { tag: "", attributes: {}, children: [] };
58
+ const open: Element[] = [document];
59
+ let consumed = 0;
60
+ for (const match of xml.matchAll(MARKUP)) {
61
+ if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
62
+ consumed = match.index + match[0].length;
63
+ const [, closing, tag, attributes, selfClosing] = match;
64
+ if (tag === undefined) continue;
65
+ const parent = open.at(-1);
66
+ if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
67
+ if (closing === "/") {
68
+ if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
69
+ open.pop();
70
+ continue;
71
+ }
72
+ const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
73
+ parent.children.push(element);
74
+ if (selfClosing !== "/") open.push(element);
75
+ }
76
+ if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
77
+ if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
78
+ const [root, ...extra] = document.children;
79
+ if (root?.tag !== "testsuites" || extra.length > 0) return yield* malformed("the root is not one <testsuites>");
80
+ return root;
81
+ });
82
+
83
+ function outcomeOf(testcase: Element): Outcome {
84
+ const skipped = testcase.children.find((child) => child.tag === "skipped");
85
+ if (skipped !== undefined) return skipped.attributes["message"] === "TODO" ? "todo" : "skipped";
86
+ return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
87
+ }
88
+
89
+ const collect = Effect.fnUntraced(function* (
90
+ suite: Element,
91
+ describes: readonly string[],
92
+ results: TestResult[],
93
+ ): Effect.fn.Return<void, ReportError> {
94
+ const unreadable = (tag: string) => (cause: { readonly message: string }) =>
95
+ new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
96
+ for (const child of suite.children) {
97
+ if (child.tag === "testsuite") {
98
+ const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
99
+ yield* collect(child, [...describes, name], results);
100
+ } else if (child.tag === "testcase") {
101
+ const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
102
+ results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
103
+ }
104
+ }
105
+ });
106
+
107
+ // A top-level <testsuite> is named for its file, so only the describe blocks nested in it join a test's name.
108
+ export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
109
+ const root = yield* parseElements(xml);
110
+ const results: TestResult[] = [];
111
+ for (const fileSuite of root.children) {
112
+ if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], results);
113
+ }
114
+ return results;
115
+ });
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { TEST_ENTRY_POINT } from "./gates.ts";
5
+ import { runMain, Usage } from "./main.ts";
6
+ import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
7
+
8
+ const Environment = Schema.Literals(["ci", "local"]);
9
+
10
+ export type Environment = typeof Environment.Type;
11
+
12
+ const SkipDeclaration = Schema.Struct({
13
+ file: Schema.NonEmptyString,
14
+ test: Schema.NonEmptyString,
15
+ reason: Schema.NonEmptyString,
16
+ when: Schema.optionalKey(Environment),
17
+ });
18
+
19
+ export type SkipDeclaration = typeof SkipDeclaration.Type;
20
+
21
+ export type Verdict = {
22
+ readonly environment: Environment;
23
+ readonly skipped: number;
24
+ readonly undeclared: readonly TestResult[];
25
+ readonly stale: readonly SkipDeclaration[];
26
+ readonly unjudged: number;
27
+ };
28
+
29
+ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGateError", {
30
+ message: Schema.String,
31
+ }) {}
32
+
33
+ const NAME = TEST_ENTRY_POINT.bin;
34
+ const DECLARATIONS = "package.json testSkips";
35
+ const USAGE = `usage: ${NAME} takes no arguments, since a narrowed run skips every test it leaves out; run bun test --randomize <args> for one`;
36
+
37
+ const decodeManifest = Schema.decodeUnknownEffect(
38
+ Schema.fromJsonString(Schema.Struct({ testSkips: Schema.optionalKey(Schema.Array(SkipDeclaration)) })),
39
+ );
40
+
41
+ function matches(declaration: SkipDeclaration, result: TestResult): boolean {
42
+ return declaration.file === result.file && declaration.test === result.name;
43
+ }
44
+
45
+ function byPlace(a: TestResult, b: TestResult): number {
46
+ return a.file.localeCompare(b.file) || a.line - b.line || a.name.localeCompare(b.name);
47
+ }
48
+
49
+ export function judgeSkips(
50
+ results: readonly TestResult[],
51
+ declarations: readonly SkipDeclaration[],
52
+ environment: Environment,
53
+ ): Verdict {
54
+ const skipped = results.filter((result) => result.outcome === "skipped" || result.outcome === "todo");
55
+ const applying = declarations.filter((declaration) => (declaration.when ?? environment) === environment);
56
+ return {
57
+ environment,
58
+ skipped: skipped.length,
59
+ undeclared: skipped.filter((result) => !applying.some((declaration) => matches(declaration, result))).sort(byPlace),
60
+ stale: applying.filter((declaration) => !skipped.some((result) => matches(declaration, result))),
61
+ unjudged: declarations.length - applying.length,
62
+ };
63
+ }
64
+
65
+ function refusedStale(verdict: Verdict): readonly SkipDeclaration[] {
66
+ return verdict.environment === "ci" ? verdict.stale : [];
67
+ }
68
+
69
+ export function passes(verdict: Verdict): boolean {
70
+ return verdict.undeclared.length === 0 && refusedStale(verdict).length === 0;
71
+ }
72
+
73
+ function staleLine(declaration: SkipDeclaration): string {
74
+ return ` ${declaration.file}${NAME_SEPARATOR}${declaration.test}: declared, but no such test skipped; delete the declaration`;
75
+ }
76
+
77
+ function verdictLines(verdict: Verdict): readonly string[] {
78
+ const { environment, skipped, undeclared, unjudged } = verdict;
79
+ const run = `this ${environment} run`;
80
+ if (passes(verdict)) {
81
+ const other = environment === "ci" ? "local" : "ci";
82
+ const aside = unjudged === 0 ? "" : `; ${unjudged} declaration(s) for ${other} not judged in ${run}`;
83
+ return [skipped === 0 ? `${NAME}: no test skipped${aside}` : `${NAME}: ${skipped} skipped test(s), each declared in ${DECLARATIONS}${aside}`];
84
+ }
85
+ const stale = refusedStale(verdict);
86
+ const counted = environment === "ci" ? ` and ${stale.length} declaration(s) matching no skipped test` : "";
87
+ return [
88
+ `${NAME}: ${undeclared.length} skipped test(s) undeclared${counted} in ${run}:`,
89
+ ...undeclared.map((result) => {
90
+ const kind = result.outcome === "todo" ? "a todo" : "skipped";
91
+ return ` ${result.file}:${result.line} ${result.name}: ${kind} with no declaration; run it, or declare it in ${DECLARATIONS} with its reason`;
92
+ }),
93
+ ...stale.map(staleLine),
94
+ ];
95
+ }
96
+
97
+ export function report(verdict: Verdict): string {
98
+ const warned = verdict.environment === "local" ? verdict.stale : [];
99
+ const warning =
100
+ warned.length === 0
101
+ ? []
102
+ : [
103
+ `${NAME}: warning: ${warned.length} declaration(s) matching no skipped test in this local run, refused only in a ci run:`,
104
+ ...warned.map(staleLine),
105
+ ];
106
+ return [...verdictLines(verdict), ...warning].join("\n");
107
+ }
108
+
109
+ const readDeclarations = Effect.fn("readDeclarations")(function* (root: string) {
110
+ const manifest = (yield* Path.Path).join(root, "package.json");
111
+ const { testSkips } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
112
+ Effect.flatMap(decodeManifest),
113
+ Effect.mapError((cause) => new SkipGateError({ message: `cannot read ${DECLARATIONS}: ${cause.message}` })),
114
+ );
115
+ return testSkips ?? [];
116
+ });
117
+
118
+ const environmentOf = Config.Boolean("CI").pipe(
119
+ Config.withDefault(false),
120
+ Config.map((ci): Environment => (ci ? "ci" : "local")),
121
+ Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
122
+ );
123
+
124
+ const runSuite = Effect.fn("runSuite")(function* (outfile: string) {
125
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
126
+ const args = ["test", "--randomize", ...reporterArgs(outfile)];
127
+ return yield* spawner.exitCode(
128
+ ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
129
+ );
130
+ });
131
+
132
+ const testEntry = Effect.gen(function* () {
133
+ if (process.argv.length > 2) return yield* new Usage({ message: USAGE });
134
+ const fs = yield* FileSystem.FileSystem;
135
+ const declarations = yield* readDeclarations(process.cwd());
136
+ const environment = yield* environmentOf;
137
+
138
+ const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
139
+ const suitePassed = (yield* runSuite(outfile)) === ChildProcessSpawner.ExitCode(0);
140
+ if (!(yield* fs.exists(outfile))) {
141
+ if (!suitePassed) {
142
+ yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
143
+ return false;
144
+ }
145
+ return yield* new ReportError({ message: `bun test passed but wrote no report to ${outfile}` });
146
+ }
147
+
148
+ const verdict = judgeSkips(yield* parseReport(yield* fs.readFileString(outfile)), declarations, environment);
149
+ yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
150
+ return suitePassed && passes(verdict);
151
+ }).pipe(Effect.scoped);
152
+
153
+ if (import.meta.main) runMain(NAME, testEntry);