@avi2dg/checks 0.71.0 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.73.0
6
+
7
+ Released 2026-11-07.
8
+
9
+ ### Features
10
+
11
+ - **testing:** run unit tier plus touched e2e files with checks-test --changed [#61](https://github.com/avi2d/checks/pull/61)
12
+
13
+ ## 0.72.0
14
+
15
+ Released 2026-11-07.
16
+
17
+ ### Features
18
+
19
+ - **checks-setup:** write root AGENTS.md and CLAUDE.md when absent [#60](https://github.com/avi2d/checks/pull/60)
20
+
5
21
  ## 0.71.0
6
22
 
7
23
  Released 2026-11-07.
package/docs/bins.md CHANGED
@@ -44,6 +44,7 @@ A whole-tree-run workflow the repository writes runs them with `checks-lint --wh
44
44
  These bins run on their own:
45
45
 
46
46
  - [`checks-test`](gates/checks-test.md) runs the suite as `scripts.test` and refuses a skip without a reason at its test site.
47
+ With `--changed` it runs only the unit tier and the e2e test files a change touches.
47
48
  - [`checks-flake`](gates/checks-flake.md) runs the suite when started by hand and records the seeds a flaky test fails with.
48
49
  - [`checks-mutation`](gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
49
50
  - [`checks-mutation-compare`](gates/checks-mutation-compare.md) compares the mutants of two reports and lists each one that regresses.
@@ -31,6 +31,7 @@ A `--private` run pins each job to `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
31
31
  Setting the `CI_RUNS_ON` variable to a self-hosted runner label then lands the jobs there.
32
32
  A public run pins each job to `ubuntu-latest`.
33
33
  It copies each kit template under `dist/templates/` into `templates/`, so a new doc file starts from a copy there.
34
+ It copies `dist/templates/agents.md` to `AGENTS.md` and `dist/templates/claude.md` to `CLAUDE.md` when the root lacks them, and keeps either file it finds.
34
35
  It names every file it wrote and every file it kept.
35
36
  It tells the owner to declare the repository owners in `package.json` `author` or `contributors` before the first commit.
36
37
 
@@ -69,6 +70,8 @@ checks-setup: set the CI_RUNS_ON variable to a self-hosted runner label so the n
69
70
 
70
71
  A repository owner runs it once in a new repository, before the first install.
71
72
  Declare the repository owners in `package.json` `author` or `contributors` before the first commit.
73
+ Fill `AGENTS.md` with what an agent cannot infer from the code before the first commit.
74
+ `checks-docs` fails on the unfilled placeholder entry until it is filled.
72
75
  Running it again with the same kit version changes nothing.
73
76
  A later kit release arrives through [Upgrade the kit](../how-to/upgrade-the-kit.md).
74
77
 
@@ -27,7 +27,7 @@ It fails unless the repository holds this shape, and names the file and the path
27
27
  A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
28
28
  Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
29
29
  - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize --parallel=<workers>` as [checks-test](checks-test.md#parallel-runs) says.
30
- It refuses `--full`, so a local run stays capped.
30
+ It refuses `--full` and `--changed`, so a local run stays capped and CI still runs the suite it reaches.
31
31
  - `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
32
32
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
33
33
  `[test].pathIgnorePatterns` is the preset's `["**/tests/quarantine/**", "**/tests/live/**", "**/tests/pixel/**", "repos/**"]`, which the check pins itself, so the kit's own repository, whose bunfig is the preset, cannot drift it either.
@@ -8,7 +8,7 @@ audience: consumers
8
8
 
9
9
  ## What it checks
10
10
 
11
- It runs the unit tier plus the smoke list outside CI and the full suite under CI.
11
+ It runs the unit tier plus the smoke list outside CI and the full suite under CI, except a `--changed` run, as [Changed-file runs](#changed-file-runs) says.
12
12
  It runs them with `bun test --randomize --parallel=<workers>`, as [Parallel runs](#parallel-runs) says, and reads Bun's JUnit report from that run.
13
13
  It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
14
14
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
@@ -46,13 +46,14 @@ A skipped test or a todo without `skipReason` fails in every environment.
46
46
 
47
47
  `checks-test` reads test source files and the JUnit report written by its own run.
48
48
  A pull request run also reads the event file, the git history between the base and the head, the tracked sources, `package.json` and `bunfig.toml`.
49
+ A `--changed` run also reads the git history since the merge base with `origin/<default branch>`, the working tree diff and the untracked files, as [Changed-file runs](#changed-file-runs) says.
49
50
  It reads no skip list from `package.json`, only the smoke list.
50
51
  It counts a run as `ci` when `CI` is true and as `local` otherwise.
51
- A local run covers `./tests/unit` plus the smoke list the repository declares.
52
+ A local run covers `./tests/unit` plus the smoke list the repository declares, except a `--changed` run, as [Changed-file runs](#changed-file-runs) says.
52
53
  The smoke list lives in `package.json` under `checks.smoke` as an array of test file paths.
53
54
  A repository with no smoke list runs the unit tier alone outside CI.
54
55
  A local run with no unit test and no smoke list runs nothing and passes.
55
- A `ci` run covers the full default suite, unless a pull request narrows it, as [Pull request runs](#pull-request-runs) says.
56
+ A `ci` run covers the full default suite, unless a pull request narrows it or `--changed` narrows it, as [Pull request runs](#pull-request-runs) and [Changed-file runs](#changed-file-runs) say.
56
57
  A smoke entry that names no test file fails the run.
57
58
  A smoke entry under `tests/quarantine/`, `tests/live/` or `tests/pixel/` fails the run, since Bun's ignored paths would drop it.
58
59
 
@@ -98,6 +99,25 @@ A run on any other event, such as `workflow_dispatch` for a release, runs the wh
98
99
  `--full` and the live and pixel tiers always run whole.
99
100
  A narrowed run judges only the skips the files it ran declare.
100
101
 
102
+ ## Changed-file runs
103
+
104
+ `checks-test --changed` runs the unit tier and each e2e test file the change touches, so a desk run stays short.
105
+ The change is every path between where `HEAD` branched from `origin/<default branch>` and `HEAD`, plus the working tree's edits and its untracked files.
106
+ A deleted file drops out, and a renamed file counts under its new name.
107
+ An e2e test file is a `.test.ts` or `.test.tsx` file under `tests/e2e/`, so a helper under `tests/e2e/lib/` and a quarantined test never run.
108
+ The run names each e2e test file it picked, or says the change touches none and only the unit tier runs.
109
+ It judges the skips of the files it ran, as any other run does.
110
+ A clone with no remote-tracking default branch fails before any test runs and asks for a fetch of it.
111
+ A repository runs it from a script of its own, such as:
112
+
113
+ ```json
114
+ {
115
+ "scripts": {
116
+ "test:fast": "checks-test --changed"
117
+ }
118
+ }
119
+ ```
120
+
101
121
  ## Test tiers
102
122
 
103
123
  Tests that need a live machine go in `tests/live/`, and tests that need a screen go in `tests/pixel/`.
@@ -146,9 +166,11 @@ A timings option waits for Bun to honour timings under random order.
146
166
 
147
167
  ## Arguments
148
168
 
149
- The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says, `--full` and `--junit=<file>`.
169
+ The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says, `--full`, `--changed` and `--junit=<file>`.
150
170
  `--full` runs the full suite outside CI, so a run started by hand covers what a local run skips.
151
171
  A `package.json` script never takes `--full`, as [checks-test-layout](checks-test-layout.md) says of `scripts.test`.
172
+ `--changed` runs the unit tier and the e2e test files the change touches, as [Changed-file runs](#changed-file-runs) says, and takes neither a tier nor `--full`.
173
+ `scripts.test` never takes `--changed` either, so CI still runs the suite it reaches.
152
174
  Its named tier options are `--tier=live`, `--tier=pixel`, `--tier=unit` and `--tier=e2e`, and a tier run takes `--parallel=<workers>` too, but not `--full`.
153
175
  `--junit=<file>` keeps the JUnit report Bun wrote at `<file>`, whether the tests passed or failed, so a whole-tree run hands it to [checks-whole-tree-run](checks-whole-tree-run.md).
154
176
  A `package.json` script never takes `--junit`, as [checks-test-layout](checks-test-layout.md) says of `scripts.test`.
@@ -161,7 +183,7 @@ It refuses test filters because every test excluded by a filter would look skipp
161
183
  | --- | --- |
162
184
  | 0 | every test passed and every skip has a matching site reason |
163
185
  | 1 | a test failed, a parallel worker crashed, a skip lacks a site reason or a declaration is stale in CI |
164
- | 2 | an argument is not one it takes, a declaration or the smoke list cannot be read, a smoke entry names no runnable test, `CI` is not a boolean or Bun wrote no report |
186
+ | 2 | an argument is not one it takes, a declaration or the smoke list cannot be read, a smoke entry names no runnable test, `CI` is not a boolean, Bun wrote no report or a `--changed` run cannot find where `HEAD` branched from `origin/<default branch>` |
165
187
 
166
188
  ## Sample output
167
189
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.71.0",
3
+ "version": "0.73.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -55,6 +55,7 @@
55
55
  "src/testing/test.ts",
56
56
  "src/testing/test-skips.ts",
57
57
  "src/testing/affected-tests.ts",
58
+ "src/testing/changed-tests.ts",
58
59
  "src/core/swc.ts",
59
60
  "src/testing/test-report.ts",
60
61
  "src/testing/flake.ts",
@@ -221,7 +222,7 @@
221
222
  "lint": "oxlint && bun src/core/lint.ts",
222
223
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
223
224
  "test": "bun src/testing/test.ts",
224
- "test:fast": "bun scripts/test-fast.ts",
225
+ "test:fast": "bun src/testing/test.ts --changed",
225
226
  "test:unit": "bun src/testing/test.ts --tier=unit",
226
227
  "test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
227
228
  "test:live": "bun src/testing/test.ts --tier=live",
@@ -2,7 +2,7 @@
2
2
  import { BunServices } from "@effect/platform-bun";
3
3
  import { Config, Console, Duration, Effect, FileSystem, Path, Schema } from "effect";
4
4
  import { LINTED_SOURCE, TYPESCRIPT_SOURCE, type TrackedContent } from "../core/gates.ts";
5
- import { checkoutFiles, collect, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
5
+ import { checkoutFiles, collect, commitOf, existingFiles, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
6
6
  import { PASSED, runExitMain, UNDECIDED, Usage, VIOLATED } from "../core/main.ts";
7
7
  import { knipReport } from "./knip.ts";
8
8
 
@@ -233,10 +233,8 @@ const depcruise = Effect.fn("depcruise")(function* () {
233
233
  });
234
234
 
235
235
  const trackedInTree = Effect.fn("trackedInTree")(function* (root: string) {
236
- const fs = yield* FileSystem.FileSystem;
237
- const path = yield* Path.Path;
238
236
  const listed = yield* git(["ls-files", "-z", "--", ...CRUISED], root);
239
- return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), { concurrency: "unbounded" });
237
+ return yield* existingFiles(root, listed.split("\0").filter(Boolean));
240
238
  });
241
239
 
242
240
  const runSection = Effect.fn("runSection")(function* (
package/src/core/git.ts CHANGED
@@ -32,13 +32,17 @@ export const collect = Effect.fn("collect")(
32
32
  Effect.scoped,
33
33
  );
34
34
 
35
- export const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
35
+ // Effect.filter with concurrency keeps the order its checks finish in, not the order it was given.
36
+ export const existingFiles = Effect.fn("existingFiles")(function* (root: string, files: readonly string[]) {
36
37
  const fs = yield* FileSystem.FileSystem;
37
38
  const path = yield* Path.Path;
39
+ const exists = yield* Effect.forEach(files, (file) => fs.exists(path.join(root, file)), { concurrency: "unbounded" });
40
+ return files.filter((_, index) => exists[index]);
41
+ });
42
+
43
+ export const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
38
44
  const listed = yield* git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
39
- return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), {
40
- concurrency: "unbounded",
41
- });
45
+ return yield* existingFiles(root, listed.split("\0").filter(Boolean));
42
46
  });
43
47
 
44
48
  export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
@@ -130,11 +130,12 @@ const setupFile = Effect.fn("setupFile")(function* (root: string, relative: stri
130
130
  const full = path.join(root, relative);
131
131
  if (yield* system.exists(full)) {
132
132
  yield* Console.log(NAME + ": kept " + relative + ", already there");
133
- return;
133
+ return false;
134
134
  }
135
135
  yield* system.makeDirectory(path.dirname(full), { recursive: true });
136
136
  yield* system.writeFileString(full, content);
137
137
  yield* Console.log(NAME + ": wrote " + relative);
138
+ return true;
138
139
  });
139
140
 
140
141
  const setupIgnore = Effect.fn("setupIgnore")(function* (root: string) {
@@ -352,10 +353,15 @@ const check = Effect.gen(function* () {
352
353
  for (const kind of Object.keys(TEMPLATES)) {
353
354
  yield* setupFile(root, "templates/" + kind + ".md", yield* kitText("dist/templates/" + kind + ".md"));
354
355
  }
356
+ const wroteAgents = yield* setupFile(root, "AGENTS.md", yield* kitText("dist/templates/agents.md"));
357
+ yield* setupFile(root, "CLAUDE.md", yield* kitText("dist/templates/claude.md"));
355
358
  if (isPrivate) yield* Console.log(NAME + ": set the CI_RUNS_ON variable to a self-hosted runner label so the new jobs land there");
356
359
  if (!outcome.hasOwner) {
357
360
  yield* Console.log(NAME + ": declare the repository owners in package.json author or contributors before the first commit");
358
361
  }
362
+ if (wroteAgents) {
363
+ yield* Console.log(NAME + ": fill AGENTS.md with what an agent cannot infer from the code before the first commit");
364
+ }
359
365
  return true;
360
366
  });
361
367
 
@@ -0,0 +1,38 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { changedPaths, defaultBranch, existingFiles, git, rangeEnds, type Change } from "../core/git.ts";
3
+
4
+ const E2E_PREFIX = "tests/e2e/";
5
+ const TEST_SUFFIXES = [".test.ts", ".test.tsx"] as const;
6
+
7
+ class BaseUnresolved extends Schema.TaggedError<BaseUnresolved>()("BaseUnresolved", {
8
+ message: Schema.String,
9
+ }) {}
10
+
11
+ export function keptPaths(changes: readonly Change[]): readonly string[] {
12
+ return changes.filter((change) => change.kind !== "deleted").map((change) => change.path);
13
+ }
14
+
15
+ export function touchedE2eFiles(paths: readonly string[]): readonly string[] {
16
+ const picked = new Set<string>();
17
+ for (const path of paths) {
18
+ if (!path.startsWith(E2E_PREFIX)) continue;
19
+ if (!TEST_SUFFIXES.some((suffix) => path.endsWith(suffix))) continue;
20
+ picked.add(path);
21
+ }
22
+ return [...picked].sort();
23
+ }
24
+
25
+ const splitZero = (output: string): readonly string[] => output.split("\0").filter((line) => line !== "");
26
+
27
+ export const changedE2eFiles = Effect.fn("changedE2eFiles")(function* (root: string) {
28
+ const target = "origin/" + (yield* defaultBranch(root));
29
+ const range = yield* rangeEnds(target, "HEAD", root).pipe(
30
+ Effect.mapError((cause) => new BaseUnresolved({
31
+ message: "cannot find where HEAD branched from " + target + ", so the e2e files the change touches are unknown; fetch " + target + " and run again: " + cause.message,
32
+ })),
33
+ );
34
+ const committed = keptPaths(yield* changedPaths(range.base, range.head, [], root));
35
+ const working = splitZero(yield* git(["diff", "--name-only", "-z", "HEAD", "--"], root));
36
+ const untracked = splitZero(yield* git(["ls-files", "--others", "--exclude-standard", "-z"], root));
37
+ return yield* existingFiles(root, touchedE2eFiles([...committed, ...working, ...untracked]));
38
+ });
@@ -201,8 +201,12 @@ export function smokeFiles(scanned: readonly string[], smoke: readonly string[])
201
201
  message: "checks.smoke cannot name a quarantined, live or pixel test at " + ignored.join(", "),
202
202
  };
203
203
  }
204
- const wanted = new Set(smoke);
205
- return { kind: "valid", files: scanned.filter((file) => selectedTierFile(file, "unit") || wanted.has(file)) };
204
+ return { kind: "valid", files: unitTierPlus(scanned, smoke) };
205
+ }
206
+
207
+ export function unitTierPlus(scanned: readonly string[], extra: readonly string[]): readonly string[] {
208
+ const wanted = new Set(extra);
209
+ return scanned.filter((file) => selectedTierFile(file, "unit") || wanted.has(file));
206
210
  }
207
211
 
208
212
  export const readSmokeDeclarations = Effect.fn("readSmokeDeclarations")(function* (
@@ -5,12 +5,14 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
5
5
  import { TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { runMain, Usage } from "../core/main.ts";
7
7
  import { readPullRequestNarrowing } from "./affected-tests.ts";
8
+ import { changedE2eFiles } from "./changed-tests.ts";
8
9
  import {
9
10
  readDeclarationsIn,
10
11
  readSkipDeclarations,
11
12
  readSmokeDeclarations,
12
13
  scanTestFiles,
13
14
  selectedTierFile,
15
+ unitTierPlus,
14
16
  type Environment,
15
17
  type SkipDeclaration,
16
18
  type TestTier,
@@ -38,8 +40,9 @@ export type TestOptions = {
38
40
  };
39
41
 
40
42
  const NAME = TEST_ENTRY_POINT.bin;
41
- const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e] [--parallel=<workers>] [--full] [--junit=<file>], and it refuses test filters`;
43
+ const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e | --full | --changed] [--parallel=<workers>] [--junit=<file>], and it refuses test filters`;
42
44
  const FULL = "--full";
45
+ const CHANGED = "--changed";
43
46
  const TIERS = new Map<string, TestTier>([
44
47
  ["--tier=live", "live"],
45
48
  ["--tier=pixel", "pixel"],
@@ -68,14 +71,20 @@ export function testOptions(args: readonly string[]): TestOptions | undefined {
68
71
  return options;
69
72
  }
70
73
 
71
- export type EntryOptions = { readonly options: TestOptions; readonly junit: Option.Option<string> };
74
+ export type EntryOptions = {
75
+ readonly options: TestOptions;
76
+ readonly junit: Option.Option<string>;
77
+ readonly changed: boolean;
78
+ };
72
79
 
73
80
  // Kept out of testOptions, so checks-test-layout still holds a package.json test script to --parallel alone.
74
81
  export function entryOptions(args: readonly string[]): EntryOptions | undefined {
75
82
  const junit = args.flatMap((arg) => JUNIT.exec(arg)?.[1] ?? []);
76
- const options = testOptions(args.filter((arg) => !JUNIT.test(arg)));
77
- if (options === undefined || junit.length > 1) return undefined;
78
- return { options, junit: Option.fromNullishOr(junit[0]) };
83
+ const changed = args.filter((arg) => arg === CHANGED).length;
84
+ const options = testOptions(args.filter((arg) => !JUNIT.test(arg) && arg !== CHANGED));
85
+ if (options === undefined || junit.length > 1 || changed > 1) return undefined;
86
+ if (changed === 1 && (options.tier !== undefined || options.full === true)) return undefined;
87
+ return { options, junit: Option.fromNullishOr(junit[0]), changed: changed === 1 };
79
88
  }
80
89
 
81
90
  const TEST_SCRIPTS: readonly string[] = [NAME, `bun src/${TEST_ENTRY_POINT.script}`];
@@ -176,7 +185,8 @@ export type SuiteSelection =
176
185
  | { readonly kind: "full" }
177
186
  | { readonly kind: "tier"; readonly tier: TestTier }
178
187
  | { readonly kind: "smoke" }
179
- | { readonly kind: "files"; readonly files: readonly string[] };
188
+ | { readonly kind: "files"; readonly files: readonly string[] }
189
+ | { readonly kind: "changed"; readonly e2e: readonly string[] };
180
190
 
181
191
  export function selectSuite(options: TestOptions, environment: Environment): SuiteSelection {
182
192
  if (options.tier !== undefined) return { kind: "tier", tier: options.tier };
@@ -195,6 +205,7 @@ export function smokeList(manifest: unknown): readonly string[] | undefined {
195
205
 
196
206
  export function suiteArgs(selection: SuiteSelection, smoke: readonly string[]): readonly string[] {
197
207
  if (selection.kind === "tier") return tierArgs(selection.tier);
208
+ if (selection.kind === "changed") return suiteArgs({ kind: "smoke" }, selection.e2e);
198
209
  if (selection.kind === "smoke") return ["./tests/unit", ...smoke.map((file) => `./${file}`)];
199
210
  if (selection.kind === "files") return selection.files.map((file) => `./${file}`);
200
211
  return [];
@@ -248,6 +259,10 @@ const narrowSelection = Effect.fn("narrowSelection")(function* (options: TestOpt
248
259
 
249
260
  const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, smoke: readonly string[]) {
250
261
  if (selection.kind === "smoke") return yield* readSmokeDeclarations(process.cwd(), smoke);
262
+ if (selection.kind === "changed") {
263
+ const files = unitTierPlus(yield* scanTestFiles(process.cwd()), selection.e2e);
264
+ return { files, declarations: yield* readDeclarationsIn(process.cwd(), files) };
265
+ }
251
266
  const declarations =
252
267
  selection.kind === "files"
253
268
  ? yield* readDeclarationsIn(process.cwd(), selection.files)
@@ -255,6 +270,16 @@ const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, s
255
270
  return { files: undefined, declarations };
256
271
  });
257
272
 
273
+ const changedSelection = Effect.fn("changedSelection")(function* () {
274
+ const e2e = yield* changedE2eFiles(process.cwd());
275
+ yield* Console.log(
276
+ e2e.length === 0
277
+ ? `${NAME}: the change touches no e2e test files, so only the unit tier runs`
278
+ : `${NAME}: the change touches ${e2e.length} e2e test file(s): ${e2e.join(" ")}`,
279
+ );
280
+ return { kind: "changed", e2e } satisfies SuiteSelection;
281
+ });
282
+
258
283
  const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonly string[], workers: number | undefined) {
259
284
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
260
285
  const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
@@ -272,17 +297,23 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonl
272
297
  const testEntry = Effect.gen(function* () {
273
298
  const entry = entryOptions(process.argv.slice(2));
274
299
  if (entry === undefined) return yield* new Usage({ message: USAGE });
275
- const { options, junit } = entry;
300
+ const { options, junit, changed } = entry;
276
301
  const fs = yield* FileSystem.FileSystem;
277
302
  const path = yield* Path.Path;
278
303
  const environment = yield* environmentOf;
279
- const selection = yield* narrowSelection(options, selectSuite(options, environment), environment);
304
+ const selection = changed
305
+ ? yield* changedSelection()
306
+ : yield* narrowSelection(options, selectSuite(options, environment), environment);
280
307
  if (selection.kind === "files" && selection.files.length === 0) {
281
308
  yield* Console.log(`${NAME}: the pull request changes no file a test reads, so no test ran`);
282
309
  return true;
283
310
  }
284
311
  const smoke = selection.kind === "smoke" ? yield* readSmokeList(process.cwd()) : [];
285
312
  const scope = yield* readScope(selection, smoke);
313
+ if (scope.files?.length === 0 && selection.kind === "changed") {
314
+ yield* Console.log(`${NAME}: no unit test and no e2e test the change touches, so no test ran`);
315
+ return true;
316
+ }
286
317
  if (scope.files?.length === 0) {
287
318
  yield* Console.log(`${NAME}: no unit test and no smoke list, so this local run ran nothing; declare checks.smoke or pass ${FULL}`);
288
319
  return true;