@avi2dg/checks 0.72.0 → 0.74.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.74.0
6
+
7
+ Released 2026-11-07.
8
+
9
+ ### Features
10
+
11
+ - **quality:** refuse folder-level import cycles in the dependency-cruiser preset [#69](https://github.com/avi2d/checks/pull/69)
12
+
13
+ ## 0.73.0
14
+
15
+ Released 2026-11-07.
16
+
17
+ ### Features
18
+
19
+ - **testing:** run unit tier plus touched e2e files with checks-test --changed [#61](https://github.com/avi2d/checks/pull/61)
20
+
5
21
  ## 0.72.0
6
22
 
7
23
  Released 2026-11-07.
@@ -9,6 +9,16 @@ export default {
9
9
  "circular": true
10
10
  }
11
11
  },
12
+ {
13
+ "name": "no-folder-cycle",
14
+ "severity": "error",
15
+ "comment": "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
16
+ "scope": "folder",
17
+ "from": {},
18
+ "to": {
19
+ "circular": true
20
+ }
21
+ },
12
22
  {
13
23
  "name": "no-orphans",
14
24
  "severity": "error",
@@ -13,6 +13,14 @@ var BASE = {
13
13
  from: {},
14
14
  to: { circular: true }
15
15
  },
16
+ {
17
+ name: "no-folder-cycle",
18
+ severity: "error",
19
+ comment: "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
20
+ scope: "folder",
21
+ from: {},
22
+ to: { circular: true }
23
+ },
16
24
  {
17
25
  name: "no-orphans",
18
26
  severity: "error",
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.
@@ -11,6 +11,7 @@ The kit's dependency-cruiser rules hold a repository's imports, and a repository
11
11
  `defineConfig` from `@avi2dg/checks/dependency-cruiser` returns these rules, with or without a config of the repository's own:
12
12
 
13
13
  - `no-circular`
14
+ - `no-folder-cycle`, which refuses folders that reach each other in a cycle even when no module cycle joins them
14
15
  - `no-orphans`, which a repository that depends on `astro` gets only by listing its own, as [checks-graphwalk](../gates/checks-graphwalk.md) explains
15
16
  - `not-to-dev-dep`, which refuses shipped source importing a dev-only package, and a package listed in `peerDependencies` too is not dev-only
16
17
  - `no-non-package-json`, which refuses an import of an installed package that the nearest `package.json` does not declare, and counts a peer that `peerDependenciesMeta` marks optional as declared
@@ -18,6 +19,8 @@ The kit's dependency-cruiser rules hold a repository's imports, and a repository
18
19
  - `no-deep-imports`, which refuses a subpath the package's exports map does not publish
19
20
 
20
21
  A test file, a config file and every path under `tests/` may import a dev-only package.
22
+ Shipped source may import a dev-only package with `import type` or an inline `type` specifier, since swc tags that import type-only without a TypeScript compiler.
23
+ Both cycle rules count a type-only import as an edge.
21
24
 
22
25
  The rules parse with swc, so the cruise needs `@swc/core` installed, and without it the cruise silently skips every `.ts` file.
23
26
 
package/docs/design.md CHANGED
@@ -78,6 +78,7 @@ So the fragment lists the rules in `files`, and in `include` beside every file u
78
78
 
79
79
  The source sits under `src/<vector>/`, one directory for each thing the kit judges a repository on: complexity, quality, testing, docs, delivery and dependencies.
80
80
  `src/core/` holds what every vector runs on.
81
+ An area imports `src/core/` and only the other areas listed for it in `AREA_IMPORTS` in the repository's `dependency-cruiser.config.ts`, so code two areas share moves into `src/core/` rather than one area importing the other back.
81
82
  `scripts/` holds only the kit's own build and CI tooling, and nothing in it ships.
82
83
  Sorting files by what loads them would put both oxlint plugins at the root and every bin in one flat directory.
83
84
  Nothing would then say which gate a helper serves.
@@ -96,15 +97,18 @@ A path read without the resolver, such as `node_modules/@avi2dg/checks/scripts/t
96
97
 
97
98
  ## The package ships built plugins and runnable bins
98
99
 
99
- `files` in `package.json` lists what an install gets.
100
- `tests/`, `AGENTS.md` and the TypeScript source of the oxlint plugins never reach an install.
100
+ `files` in `package.json` lists what an install gets, and `tests/` and `AGENTS.md` never reach an install.
101
+ It ships `docs/`, `src/` and `dist/`, so a new bin or page ships with no edit to `files`.
102
+ Its `!` entries keep out the decision records under `docs/adr/` and the sources only the build reads.
103
+ A source a shipped bin imports ships even when the build also compiles it.
101
104
  npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
102
105
  `bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
103
106
 
104
107
  Each oxlint plugin and each config builder ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
105
108
  Stryker loads its config under Node too, so the guard plugin and run-scope helpers the Stryker preset imports ship compiled under `dist/mutation/`.
106
109
  oxlint loads `oxlint.config.ts` under Node, so the builders ship as JavaScript beside a `.d.ts` that `tsc` reads.
107
- `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`, except `readability/thin-astro`, which `tests/e2e/astro-consumer.test.ts` proves against a consumer tree linked to the checkout.
110
+ `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/package/consumer.test.ts`, except `readability/thin-astro`.
111
+ `readability/thin-astro` is proven against a consumer tree linked to the checkout in `tests/e2e/complexity/astro-consumer.test.ts`.
108
112
  `dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
109
113
  No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
110
114
  CI runs `git diff --exit-code` over the whole tree after `bun run build`.
@@ -17,7 +17,7 @@ A configuration that turns the files rule off still has its files judged.
17
17
  It holds each reported symbol against `exports-baseline.json`, and fails on any symbol the baseline does not hold.
18
18
  It fails on any baseline entry the range adds unless Knip reports that same symbol unused at the base, so the baseline records debt that already existed and never debt the range creates.
19
19
  It fails on any baseline entry Knip no longer reports, so the baseline only shrinks.
20
- It runs dependency-cruiser over every tracked `.ts`, `.tsx`, `.mts` and `.cts` file and names each violation with its rule, the importing module and the module it reaches.
20
+ It runs dependency-cruiser over every tracked `.ts`, `.tsx`, `.mts` and `.cts` file and names each violation with its rule, the importing module or folder and the module or folder it reaches.
21
21
  It fails on a violation whose rule has `error` severity, and lists a `warn` or `info` violation without failing.
22
22
  It fails when the repository tracks no `.ts`, `.tsx`, `.astro`, `.mts` or `.cts` file, since an empty scan would pass without judging anything.
23
23
  It fails when the repository holds no Knip configuration, since Knip's default entries cannot tell a dead file from an entry point.
@@ -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.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "author": {
6
6
  "name": "avi2d",
@@ -17,126 +17,26 @@
17
17
  "type": "module",
18
18
  "files": [
19
19
  "CHANGELOG.md",
20
- "docs/bins.md",
21
- "docs/configs/",
22
- "docs/design.md",
23
- "docs/gates/",
24
- "docs/how-to/",
25
- "docs/shipped-files.md",
20
+ "docs/",
21
+ "!docs/adr/",
26
22
  "bunfig.toml",
27
23
  ".bun-version",
28
24
  "commitlint.config.ts",
29
25
  "dependency-cruiser.config.js",
30
26
  "knip-base.json",
31
- "src/quality/lint-coverage.sh",
32
- "src/core/main.ts",
33
- "src/core/git.ts",
34
- "src/core/gates.ts",
35
- "src/core/declared-inputs.ts",
36
- "src/core/lint.ts",
37
- "src/core/scope.ts",
38
- "src/delivery/changelog.ts",
39
- "src/delivery/changelog-write.ts",
40
- "src/delivery/release-notes.ts",
41
- "src/delivery/release-report.ts",
42
- "src/delivery/release.ts",
43
- "src/delivery/release-commit.ts",
44
- "src/delivery/release-pr.ts",
45
- "src/delivery/release-preflight.ts",
46
- "src/delivery/release-schedule.ts",
47
- "src/delivery/schedule-policy.ts",
48
- "src/delivery/release-publish.ts",
49
- "src/delivery/release-push.ts",
50
- "src/delivery/release-tag.ts",
51
- "src/delivery/release-tagging.ts",
52
- "src/delivery/github.ts",
53
- "src/testing/test-layout.ts",
54
- "src/testing/block-screens.ts",
55
- "src/testing/test.ts",
56
- "src/testing/test-skips.ts",
57
- "src/testing/affected-tests.ts",
58
- "src/core/swc.ts",
59
- "src/testing/test-report.ts",
60
- "src/testing/flake.ts",
61
- "src/testing/flake-preload.ts",
62
- "src/delivery/commit-identity.ts",
63
- "src/delivery/commit-title.ts",
64
- "src/delivery/docs-only.ts",
65
- "src/testing/mutation.ts",
66
- "src/testing/mutation-refusal.ts",
67
- "src/testing/mutation-compare.ts",
68
- "src/testing/mutation-scope.ts",
69
- "src/testing/mutation-scope-graph.ts",
70
- "src/testing/mutation-baseline.ts",
71
- "src/testing/mutation-baseline-full.ts",
72
- "src/testing/mutation-baseline-saved.ts",
73
- "src/testing/subsumed-tests.ts",
74
- "src/testing/whole-tree-run.ts",
75
- "src/testing/whole-tree-run-findings.ts",
76
- "src/delivery/ci-wiring.ts",
77
- "src/delivery/shell-command.ts",
78
- "src/delivery/workflow-references.ts",
79
- "src/delivery/bun-pin.ts",
80
- "src/delivery/mutation-job.ts",
81
- "src/delivery/job-timeout.ts",
82
- "src/delivery/runner-policy.ts",
83
- "src/delivery/runner-pick.ts",
84
- "src/delivery/secrets.ts",
85
- "src/delivery/secrets-pack.ts",
86
- "src/delivery/pr-text.ts",
87
- "src/delivery/setup.ts",
88
- "src/delivery/setup-workflow.ts",
89
- "src/delivery/gitleaks.ts",
90
- "src/delivery/gitleaks.toml",
91
- "src/quality/comment-matchers.ts",
92
- "src/docs/prose-matchers.ts",
93
- "src/quality/comments.ts",
94
- "src/quality/comment-gate.ts",
95
- "src/quality/css-size.ts",
96
- "src/quality/frontend-syntax.ts",
97
- "src/quality/browser/",
98
- "src/complexity/suppressions-ratchet.ts",
99
- "src/complexity/repetition.ts",
100
- "src/complexity/graphwalk.ts",
101
- "src/complexity/knip.ts",
102
- "src/testing/quarantine-clock.ts",
103
- "src/docs/sentence-length.ts",
104
- "src/docs/docs.ts",
105
- "src/docs/copy.ts",
106
- "src/docs/copy-text.ts",
107
- "src/docs/copy-html.ts",
108
- "src/dependencies/vendor.ts",
109
- "src/dependencies/vendor-args.ts",
110
- "src/dependencies/cache-root.ts",
111
- "src/dependencies/advisories.ts",
112
- "src/dependencies/advisory-rules.ts",
113
- "src/dependencies/osv-scanner.ts",
114
- "src/dependencies/pinned-binary.ts",
115
- "src/docs/doc-agents.ts",
116
- "src/docs/doc-outline.ts",
117
- "src/docs/doc-rules.ts",
118
- "src/docs/doc-references.ts",
119
- "src/docs/doc-snapshot.ts",
120
- "src/docs/doc-templates.ts",
121
- "src/docs/doc-names.ts",
122
- "src/docs/doc-versions.ts",
123
- "dist/templates/",
124
- "src/quality/presets/effect.language-service.json",
125
- "src/quality/presets/oxlint.ts",
126
- "src/quality/presets/dependency-cruiser.ts",
127
- "src/quality/presets/stylelint.ts",
128
- "src/quality/effect-scope.ts",
129
- "src/quality/jsonc-patch.ts",
130
- "src/dependencies/kit-defaults.ts",
27
+ "src/",
28
+ "!src/complexity/readability/",
29
+ "!src/quality/data-shape/",
30
+ "!src/quality/effect-channel/",
31
+ "!src/quality/presets/knip.ts",
32
+ "!src/quality/presets/postcss-value-parser.d.ts",
33
+ "!src/testing/mutation-guard-plugin.ts",
34
+ "!src/testing/unit-test-order.ts",
35
+ "dist/",
131
36
  "oxlintrc.json",
132
37
  "stryker.preset.js",
133
38
  "tsconfig.effect.json",
134
- "ts-reset.d.ts",
135
- "dist/effect-channel/index.js",
136
- "dist/readability/index.js",
137
- "dist/data-shape/index.js",
138
- "dist/mutation/",
139
- "dist/presets/"
39
+ "ts-reset.d.ts"
140
40
  ],
141
41
  "exports": {
142
42
  "./dependency-cruiser.config.js": "./dependency-cruiser.config.js",
@@ -221,7 +121,7 @@
221
121
  "lint": "oxlint && bun src/core/lint.ts",
222
122
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
223
123
  "test": "bun src/testing/test.ts",
224
- "test:fast": "bun scripts/test-fast.ts",
124
+ "test:fast": "bun src/testing/test.ts --changed",
225
125
  "test:unit": "bun src/testing/test.ts --tier=unit",
226
126
  "test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
227
127
  "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) {
@@ -1,6 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { ChildProcessSpawner } from "effect/process";
3
- import { collect, type Feed } from "../core/git.ts";
3
+ import { collect, type Feed } from "./git.ts";
4
4
 
5
5
  export class GitHubFailure extends Schema.TaggedError<GitHubFailure>()("GitHubFailure", {
6
6
  message: Schema.String,
@@ -0,0 +1,14 @@
1
+ export const MANIFEST = "package.json";
2
+
3
+ export const TAG_PREFIX = "v";
4
+
5
+ export const RELEASE_TAG_GLOB = `${TAG_PREFIX}[0-9]*.[0-9]*.[0-9]*`;
6
+
7
+ export function releaseTag(version: string): string {
8
+ return `${TAG_PREFIX}${version}`;
9
+ }
10
+
11
+ // The tag starts with TAG_PREFIX.
12
+ export function taggedVersion(tag: string): string {
13
+ return tag.slice(TAG_PREFIX.length);
14
+ }
@@ -0,0 +1,45 @@
1
+ import { TEST_ENTRY_POINT } from "./gates.ts";
2
+
3
+ export type TestTier = "live" | "pixel" | "unit" | "e2e";
4
+
5
+ export type TestOptions = {
6
+ readonly tier?: TestTier;
7
+ readonly workers?: number;
8
+ readonly full?: boolean;
9
+ };
10
+
11
+ export const FULL = "--full";
12
+ const TIERS = new Map<string, TestTier>([
13
+ ["--tier=live", "live"],
14
+ ["--tier=pixel", "pixel"],
15
+ ["--tier=unit", "unit"],
16
+ ["--tier=e2e", "e2e"],
17
+ ]);
18
+ const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
19
+
20
+ function optionOf(arg: string): TestOptions | undefined {
21
+ const tier = TIERS.get(arg);
22
+ if (tier !== undefined) return { tier };
23
+ if (arg === FULL) return { full: true };
24
+ const workers = PARALLEL.exec(arg)?.[1];
25
+ return workers === undefined ? undefined : { workers: Number(workers) };
26
+ }
27
+
28
+ export function testOptions(args: readonly string[]): TestOptions | undefined {
29
+ let options: TestOptions = {};
30
+ for (const arg of args) {
31
+ const option = optionOf(arg);
32
+ if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
33
+ options = { ...options, ...option };
34
+ }
35
+ if (options.tier !== undefined && options.full === true) return undefined;
36
+ return options;
37
+ }
38
+
39
+ const TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun src/${TEST_ENTRY_POINT.script}`];
40
+
41
+ export function scriptOptions(script: unknown): TestOptions | undefined {
42
+ if (typeof script !== "string") return undefined;
43
+ const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
44
+ return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
45
+ }
@@ -2,13 +2,12 @@
2
2
  import { Console, DateTime, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { cuts, hasEntries, namesPullRequest, releaseDates, renderChangelog, type Bump, type Cut, type Release } from "./changelog.ts";
4
4
  import { git } from "../core/git.ts";
5
+ import { MANIFEST, TAG_PREFIX, taggedVersion } from "../core/release-names.ts";
5
6
  import { runMain } from "../core/main.ts";
6
7
 
7
8
  const NAME = "checks-changelog";
8
9
  const CHANGELOG = "CHANGELOG.md";
9
- const MANIFEST = "package.json";
10
10
  const FIELD = "\x1f";
11
- const TAG_PREFIX = "v";
12
11
 
13
12
  class ChangelogUnreadable extends Schema.TaggedError<ChangelogUnreadable>()("ChangelogUnreadable", {
14
13
  message: Schema.String,
@@ -75,7 +74,7 @@ const readPublishedShas = Effect.fn("readPublishedShas")(function* (root: string
75
74
  const tags = yield* git(["for-each-ref", "--format=%(refname:strip=2)", `refs/tags/${TAG_PREFIX}*`], root);
76
75
  const published = new Set<string>();
77
76
  for (const tag of tags.split("\n").filter((line) => line.startsWith(TAG_PREFIX))) {
78
- const version = tag.slice(TAG_PREFIX.length);
77
+ const version = taggedVersion(tag);
79
78
  for (const bump of bumps.filter((one) => one.version === version).toReversed()) {
80
79
  if (yield* reaches(root, bump.sha, `refs/tags/${tag}`)) {
81
80
  published.add(bump.sha);
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
4
  import { cacheRoot } from "../dependencies/cache-root.ts";
5
5
  import { pinnedGitleaks, scanDirectory, type Leak } from "./gitleaks.ts";
6
- import { gh, gitHubJson, GitHubFailure, REPOSITORY } from "./github.ts";
6
+ import { gh, gitHubJson, GitHubFailure, REPOSITORY } from "../core/github.ts";
7
7
  import { scanConfig } from "./secrets-pack.ts";
8
8
 
9
9
  const NAME = "pr-text";
@@ -1,8 +1,9 @@
1
1
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
2
  import * as Base64 from "effect/encoding/Base64";
3
- import { gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
5
5
  import { git } from "../core/git.ts";
6
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
6
7
 
7
8
  export class ReleaseRefused extends Schema.TaggedError<ReleaseRefused>()("ReleaseRefused", {
8
9
  message: Schema.String,
@@ -21,7 +22,6 @@ type Built = {
21
22
  readonly files: readonly Read[];
22
23
  };
23
24
 
24
- export const MANIFEST = "package.json";
25
25
  const REGULAR_FILE_MODES = new Set(["100644", "100755"]);
26
26
  const RAW_ENTRY = /^:(\d{6}) (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/;
27
27
 
@@ -56,9 +56,9 @@ export const versionIn = (manifest: string): Effect.Effect<string | undefined> =
56
56
 
57
57
  const releaseVersion = Effect.fn("releaseVersion")(function* (manifest: string, { since, unreleased }: Pending, untagged: string) {
58
58
  const { version } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => refused(`${MANIFEST}: ${cause.message}`)));
59
- if (since !== undefined && since !== `v${version}`) {
59
+ if (since !== undefined && since !== releaseTag(version)) {
60
60
  return yield* refused(
61
- `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; ${untagged}, or return its version to ${since.slice(1)}`,
61
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so ${releaseTag(version)} is untagged; ${untagged}, or return its version to ${taggedVersion(since)}`,
62
62
  );
63
63
  }
64
64
  const next = nextVersion(version, unreleased);
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { releaseTitle, runningWorkflow } from "./release.ts";
5
- import { buildRelease, commitRelease, hasBranch, MANIFEST, planRelease, pointBranch, versionIn } from "./release-commit.ts";
5
+ import { buildRelease, commitRelease, hasBranch, planRelease, pointBranch, versionIn } from "./release-commit.ts";
6
6
  import { git } from "../core/git.ts";
7
+ import { MANIFEST } from "../core/release-names.ts";
7
8
  import { runMain, Usage } from "../core/main.ts";
8
9
 
9
10
  type Remote = {
@@ -4,11 +4,11 @@ import { lastTag } from "./release.ts";
4
4
  import { refused } from "./release-commit.ts";
5
5
  import { fromProgram, shellWords, type Command } from "./shell-command.ts";
6
6
  import { git } from "../core/git.ts";
7
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
7
8
  import { runMain, Usage } from "../core/main.ts";
8
9
 
9
10
  const LABEL = "release-preflight";
10
11
  const USAGE = "usage: release-preflight.ts <release-workflow>";
11
- const MANIFEST = "package.json";
12
12
  const WORKFLOWS = ".github/workflows";
13
13
 
14
14
  const Manifest = Schema.Struct({
@@ -114,9 +114,9 @@ const checkTag = Effect.fn("checkTag")(function* (root: string) {
114
114
  }
115
115
  const { version } = yield* readManifest(root);
116
116
  const since = yield* lastTag(root);
117
- if (since !== undefined && since !== `v${version}`) {
117
+ if (since !== undefined && since !== releaseTag(version)) {
118
118
  return yield* refused(
119
- `${MANIFEST} holds ${version}, but the last release tag is ${since}, so v${version} is untagged; rerun the release that bumped it, whose refusal says what to fix, or return ${MANIFEST} to ${since.slice(1)}`,
119
+ `${MANIFEST} holds ${version}, but the last release tag is ${since}, so ${releaseTag(version)} is untagged; rerun the release that bumped it, whose refusal says what to fix, or return ${MANIFEST} to ${taggedVersion(since)}`,
120
120
  );
121
121
  }
122
122
  yield* Console.log(`${LABEL}: ${MANIFEST} holds ${version}, which ${since === undefined ? "has no release tag yet" : `matches ${since}`}`);
@@ -2,12 +2,12 @@
2
2
  import { Console, Effect, FileSystem, Option, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { collect } from "../core/git.ts";
5
+ import { MANIFEST } from "../core/release-names.ts";
5
6
  import { runMain, Usage } from "../core/main.ts";
6
7
 
7
8
  const NAME = "checks-release-publish";
8
9
  const LABEL = "release-publish";
9
10
  const USAGE = "usage: release-publish.ts";
10
- const MANIFEST = "package.json";
11
11
 
12
12
  class PublishFailed extends Schema.TaggedError<PublishFailed>()("PublishFailed", {
13
13
  message: Schema.String,
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Duration, Effect, Schema } from "effect";
3
- import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
3
+ import { dispatch, gitHubJson, REPOSITORY } from "../core/github.ts";
4
4
  import { releasedVersionOf } from "./release.ts";
5
5
  import { buildRelease, commitRelease, decodeRef, hasBranch, planRelease, pointBranch, refused } from "./release-commit.ts";
6
6
  import { pushTag, tagHead, taggedOn } from "./release-tagging.ts";
7
7
  import { git } from "../core/git.ts";
8
+ import { releaseTag } from "../core/release-names.ts";
8
9
  import { runMain, Usage } from "../core/main.ts";
9
10
 
10
11
  const NAME = "checks-release-push";
@@ -124,7 +125,7 @@ const releasePush = Effect.gen(function* () {
124
125
  const plan = yield* planRelease(LABEL, root, UNTAGGED);
125
126
  if (plan === undefined) return true;
126
127
  const { base, branch, head, next, bumped } = plan;
127
- const tag = `v${next}`;
128
+ const tag = releaseTag(next);
128
129
  const taken = yield* taggedOn(root, tag);
129
130
  if (taken !== undefined) return yield* refused(`${tag} already tags ${taken.slice(0, 12)}, so ${next} cannot be released again`);
130
131
  const release = yield* commitRelease(root, head, yield* buildRelease(NAME, root, bumped), next);
@@ -23,7 +23,7 @@ const PUBLISH_BINS = ["checks-release-publish", "release-publish.ts"];
23
23
  const NUMBER = /^\d+$/;
24
24
  const BY_HAND = "drop the schedule, so a release runs only when someone starts it";
25
25
 
26
- function isBinIn(bins: readonly string[], word: string): boolean {
26
+ export function isBinIn(bins: readonly string[], word: string): boolean {
27
27
  return bins.some((name) => word === name || word.endsWith(`/${name}`));
28
28
  }
29
29
 
@@ -57,7 +57,7 @@ function workflowRuns(
57
57
  });
58
58
  }
59
59
 
60
- function crons(document: unknown): readonly unknown[] {
60
+ export function crons(document: unknown): readonly unknown[] {
61
61
  const on = isRecord(document) ? document["on"] : undefined;
62
62
  const schedule = isRecord(on) ? on["schedule"] : undefined;
63
63
  return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
@@ -1,14 +1,14 @@
1
1
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
2
- import { dispatch, GitHubFailure } from "./github.ts";
2
+ import { dispatch, GitHubFailure } from "../core/github.ts";
3
3
  import { ChildProcessSpawner } from "effect/process";
4
4
  import { lastTag, releasedVersionOf, runBuild } from "./release.ts";
5
5
  import { git } from "../core/git.ts";
6
+ import { MANIFEST, releaseTag, taggedVersion } from "../core/release-names.ts";
6
7
 
7
8
  class TagRefused extends Schema.TaggedError<TagRefused>()("TagRefused", {
8
9
  message: Schema.String,
9
10
  }) {}
10
11
 
11
- const MANIFEST = "package.json";
12
12
 
13
13
  const decodeVersion = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })));
14
14
 
@@ -48,7 +48,7 @@ export const tagHead = Effect.fn("tagHead")(function* (
48
48
  const manifest = yield* (yield* FileSystem.FileSystem).readFileString((yield* Path.Path).join(root, MANIFEST));
49
49
  const { version: held } = yield* decodeVersion(manifest).pipe(Effect.mapError((cause) => new TagRefused({ message: `${MANIFEST}: ${cause.message}` })));
50
50
  if (held !== version) return yield* new TagRefused({ message: `${head.slice(0, 12)} releases ${version}, but ${MANIFEST} holds ${held}` });
51
- const tag = `v${version}`;
51
+ const tag = releaseTag(version);
52
52
  const tagged = yield* taggedOn(root, tag);
53
53
  if (tagged === head) {
54
54
  yield* Console.log(`${label}: ${tag} already tags ${head.slice(0, 12)}`);
@@ -59,7 +59,7 @@ export const tagHead = Effect.fn("tagHead")(function* (
59
59
  const rebuilt = (yield* git(["diff", "--name-only"], root)).trim().split("\n").filter((file) => file !== "");
60
60
  if (rebuilt.length > 0) {
61
61
  const since = yield* lastTag(root);
62
- const returned = since === undefined ? "the version it held before this release" : since.slice(1);
62
+ const returned = since === undefined ? "the version it held before this release" : taggedVersion(since);
63
63
  return yield* new TagRefused({
64
64
  message: `the build rewrites ${rebuilt.join(", ")} at ${head.slice(0, 12)}, which the release workflow's build check refuses; open a \`chore: cancel the unpublished ${version}\` pull request that returns ${MANIFEST} to ${returned} and commits what \`bun run build\` then writes to CHANGELOG.md, and the next ${cuttingWorkflow} run cuts the release again with every change since ${since ?? "the first commit"}`,
65
65
  });
@@ -2,6 +2,7 @@ import { Config, Effect, Schema } from "effect";
2
2
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
3
  import { groupOf } from "./changelog.ts";
4
4
  import { git } from "../core/git.ts";
5
+ import { RELEASE_TAG_GLOB } from "../core/release-names.ts";
5
6
 
6
7
  class ReleaseUnreadable extends Schema.TaggedError<ReleaseUnreadable>()("ReleaseUnreadable", {
7
8
  message: Schema.String,
@@ -16,7 +17,6 @@ export type Pending = {
16
17
  readonly unreleased: readonly string[];
17
18
  };
18
19
 
19
- const RELEASE_TAG = "v[0-9]*.[0-9]*.[0-9]*";
20
20
  const PLAIN_VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
21
21
  const RELEASE_SUBJECT = /^chore: release (\d+\.\d+\.\d+)(?: \(#\d+\))?$/;
22
22
 
@@ -59,7 +59,7 @@ export const runBuild = Effect.fn("runBuild")(function* (root: string) {
59
59
  });
60
60
 
61
61
  export const lastTag = Effect.fn("lastTag")(function* (root: string) {
62
- const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG}`, "HEAD"], root).pipe(
62
+ const described = yield* git(["describe", "--tags", "--abbrev=0", `--match=${RELEASE_TAG_GLOB}`, "HEAD"], root).pipe(
63
63
  Effect.map((tag) => tag.trim()),
64
64
  Effect.catchTag("GitFailure", () => Effect.succeed("")),
65
65
  );
@@ -1,8 +1,8 @@
1
1
  import { runsBin } from "./mutation-job.ts";
2
- import { releasesOnSchedule, schedules } from "./release-schedule.ts";
2
+ import { crons, isBinIn, releasesOnSchedule, schedules } from "./release-schedule.ts";
3
3
  import { jobRuns } from "./runner-policy.ts";
4
4
  import { invokes, plainCommand, type Command } from "./shell-command.ts";
5
- import { isRecord, locate, workflowJobs, type Workflow } from "./workflow-references.ts";
5
+ import { locate, workflowJobs, type Workflow } from "./workflow-references.ts";
6
6
  import type { ScheduleContext, ScheduleOwner } from "./release-schedule.ts";
7
7
 
8
8
  export type ScheduledRun = {
@@ -17,13 +17,7 @@ const WEEKDAY = /^(?:[0-7]|sun|mon|tue|wed|thu|fri|sat)$/i;
17
17
  const BY_HAND = "drop the schedule, so it runs only when someone starts it with gh workflow run";
18
18
 
19
19
  function isAdvisoryBin([head = ""]: Command): boolean {
20
- return ADVISORY_BINS.some((name) => head === name || head.endsWith(`/${name}`));
21
- }
22
-
23
- function crons(document: unknown): readonly unknown[] {
24
- const on = isRecord(document) ? document["on"] : undefined;
25
- const schedule = isRecord(on) ? on["schedule"] : undefined;
26
- return Array.isArray(schedule) ? schedule.map((entry) => (isRecord(entry) ? entry["cron"] : undefined)) : [];
20
+ return isBinIn(ADVISORY_BINS, head);
27
21
  }
28
22
 
29
23
  // Once a week is one minute, one hour and one weekday, on any day of any month.
@@ -3,7 +3,7 @@ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { runMain, Usage } from "../core/main.ts";
4
4
  import { ciWorkflow } from "./setup-workflow.ts";
5
5
  import { TEMPLATES } from "../docs/doc-templates.ts";
6
- import { scriptOptions } from "../testing/test.ts";
6
+ import { scriptOptions } from "../core/test-script.ts";
7
7
  import { withServiceOverrides, type ServiceOverride } from "../quality/effect-scope.ts";
8
8
  import { SOURCES, TESTS } from "../quality/presets/oxlint.ts";
9
9
  import severities from "../quality/presets/effect.language-service.json" with { type: "json" };
@@ -102,9 +102,12 @@ function cellsOf(raw: string): readonly string[] {
102
102
 
103
103
  const DELIMITER_CELL = /^\s*:?-+:?\s*$/;
104
104
 
105
+ function isQuoteOrIndentedCode(raw: string): boolean {
106
+ return /^\s*>/.test(raw) || /^(?: {4}|\t)/.test(raw);
107
+ }
108
+
105
109
  function isDelimiterRow(raw: string): boolean {
106
- if (/^\s*>/.test(raw)) return false;
107
- if (/^(?: {4}|\t)/.test(raw)) return false;
110
+ if (isQuoteOrIndentedCode(raw)) return false;
108
111
  const trimmed = raw.trim();
109
112
  if (!trimmed.includes("|") || !trimmed.includes("-")) return false;
110
113
  const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
@@ -125,22 +128,18 @@ function codeLinesIn(scanned: readonly MarkdownLine[]): ReadonlySet<number> {
125
128
  return excluded;
126
129
  }
127
130
 
131
+ function isOutsideCodeAndQuote(line: MarkdownLine, code: ReadonlySet<number>): boolean {
132
+ return !code.has(line.line) && !isQuoteOrIndentedCode(line.raw);
133
+ }
134
+
128
135
  function isTableCandidate(line: MarkdownLine, code: ReadonlySet<number>): boolean {
129
- if (line.kind !== "prose" && line.kind !== "table") return false;
130
- if (code.has(line.line)) return false;
131
- if (/^\s*>/.test(line.raw)) return false;
132
- if (/^(?: {4}|\t)/.test(line.raw)) return false;
133
- return true;
136
+ return (line.kind === "prose" || line.kind === "table") && isOutsideCodeAndQuote(line, code);
134
137
  }
135
138
 
136
139
  type TableRow = { readonly line: number; readonly raw: string; readonly width: number };
137
140
 
138
141
  function isBodyLine(line: MarkdownLine, code: ReadonlySet<number>): boolean {
139
- if (line.raw.trim() === "") return false;
140
- if (code.has(line.line)) return false;
141
- if (/^\s*>/.test(line.raw)) return false;
142
- if (/^(?: {4}|\t)/.test(line.raw)) return false;
143
- return true;
142
+ return line.raw.trim() !== "" && isOutsideCodeAndQuote(line, code);
144
143
  }
145
144
 
146
145
  type HeaderMatch = { readonly header: MarkdownLine; readonly delimiterLine: number; readonly width: number };
@@ -32,6 +32,15 @@ const BASE = {
32
32
  from: {},
33
33
  to: { circular: true },
34
34
  },
35
+ {
36
+ name: "no-folder-cycle",
37
+ severity: "error",
38
+ comment:
39
+ "Folders reach each other in a cycle, so none stands without the others. Move the module that reaches back into the folder it imports, or move what the folders need into a third folder they all import.",
40
+ scope: "folder",
41
+ from: {},
42
+ to: { circular: true },
43
+ },
35
44
  {
36
45
  name: "no-orphans",
37
46
  severity: "error",
@@ -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
+ });
@@ -3,7 +3,8 @@ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } fro
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
4
  import { runMain, Usage } from "../core/main.ts";
5
5
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
- import { scriptOptions, workerCount } from "./test.ts";
6
+ import { scriptOptions } from "../core/test-script.ts";
7
+ import { workerCount } from "./test.ts";
7
8
 
8
9
  export type Run = {
9
10
  readonly seed: number;
@@ -4,7 +4,7 @@ import { workingTreeFiles } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { ENTRY_POINT, TEST_ENTRY_POINT } from "../core/gates.ts";
6
6
  import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
7
- import { scriptOptions } from "./test.ts";
7
+ import { scriptOptions } from "../core/test-script.ts";
8
8
 
9
9
  export type Violation = {
10
10
  readonly file: string;
@@ -1,8 +1,9 @@
1
1
  import { Effect, FileSystem, Path, Schema } from "effect";
2
2
  import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
3
+ import type { TestTier } from "../core/test-script.ts";
3
4
 
4
5
  export type Environment = "ci" | "local";
5
- export type TestTier = "live" | "pixel" | "unit" | "e2e";
6
+ export type { TestTier };
6
7
  export type SkipDeclaration = {
7
8
  readonly file: string;
8
9
  readonly line: number;
@@ -201,8 +202,12 @@ export function smokeFiles(scanned: readonly string[], smoke: readonly string[])
201
202
  message: "checks.smoke cannot name a quarantined, live or pixel test at " + ignored.join(", "),
202
203
  };
203
204
  }
204
- const wanted = new Set(smoke);
205
- return { kind: "valid", files: scanned.filter((file) => selectedTierFile(file, "unit") || wanted.has(file)) };
205
+ return { kind: "valid", files: unitTierPlus(scanned, smoke) };
206
+ }
207
+
208
+ export function unitTierPlus(scanned: readonly string[], extra: readonly string[]): readonly string[] {
209
+ const wanted = new Set(extra);
210
+ return scanned.filter((file) => selectedTierFile(file, "unit") || wanted.has(file));
206
211
  }
207
212
 
208
213
  export const readSmokeDeclarations = Effect.fn("readSmokeDeclarations")(function* (
@@ -4,20 +4,23 @@ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effec
4
4
  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
+ import { FULL, testOptions, type TestOptions, type TestTier } from "../core/test-script.ts";
7
8
  import { readPullRequestNarrowing } from "./affected-tests.ts";
9
+ import { changedE2eFiles } from "./changed-tests.ts";
8
10
  import {
9
11
  readDeclarationsIn,
10
12
  readSkipDeclarations,
11
13
  readSmokeDeclarations,
12
14
  scanTestFiles,
13
15
  selectedTierFile,
16
+ unitTierPlus,
14
17
  type Environment,
15
18
  type SkipDeclaration,
16
- type TestTier,
17
19
  } from "./test-skips.ts";
18
20
  import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
19
21
 
20
22
  export type { Environment, SkipDeclaration } from "./test-skips.ts";
23
+ export { scriptOptions, testOptions, type TestOptions } from "../core/test-script.ts";
21
24
 
22
25
  export type Verdict = {
23
26
  readonly environment: Environment;
@@ -31,59 +34,25 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
31
34
  message: Schema.String,
32
35
  }) {}
33
36
 
34
- export type TestOptions = {
35
- readonly tier?: TestTier;
36
- readonly workers?: number;
37
- readonly full?: boolean;
38
- };
39
-
40
37
  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`;
42
- const FULL = "--full";
43
- const TIERS = new Map<string, TestTier>([
44
- ["--tier=live", "live"],
45
- ["--tier=pixel", "pixel"],
46
- ["--tier=unit", "unit"],
47
- ["--tier=e2e", "e2e"],
48
- ]);
49
- const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
38
+ const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e | --full | --changed] [--parallel=<workers>] [--junit=<file>], and it refuses test filters`;
39
+ const CHANGED = "--changed";
50
40
  const JUNIT = /^--junit=(.+)$/;
51
41
 
52
- function optionOf(arg: string): TestOptions | undefined {
53
- const tier = TIERS.get(arg);
54
- if (tier !== undefined) return { tier };
55
- if (arg === FULL) return { full: true };
56
- const workers = PARALLEL.exec(arg)?.[1];
57
- return workers === undefined ? undefined : { workers: Number(workers) };
58
- }
59
-
60
- export function testOptions(args: readonly string[]): TestOptions | undefined {
61
- let options: TestOptions = {};
62
- for (const arg of args) {
63
- const option = optionOf(arg);
64
- if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
65
- options = { ...options, ...option };
66
- }
67
- if (options.tier !== undefined && options.full === true) return undefined;
68
- return options;
69
- }
70
-
71
- export type EntryOptions = { readonly options: TestOptions; readonly junit: Option.Option<string> };
42
+ export type EntryOptions = {
43
+ readonly options: TestOptions;
44
+ readonly junit: Option.Option<string>;
45
+ readonly changed: boolean;
46
+ };
72
47
 
73
48
  // Kept out of testOptions, so checks-test-layout still holds a package.json test script to --parallel alone.
74
49
  export function entryOptions(args: readonly string[]): EntryOptions | undefined {
75
50
  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]) };
79
- }
80
-
81
- const TEST_SCRIPTS: readonly string[] = [NAME, `bun src/${TEST_ENTRY_POINT.script}`];
82
-
83
- export function scriptOptions(script: unknown): TestOptions | undefined {
84
- if (typeof script !== "string") return undefined;
85
- const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
86
- return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
51
+ const changed = args.filter((arg) => arg === CHANGED).length;
52
+ const options = testOptions(args.filter((arg) => !JUNIT.test(arg) && arg !== CHANGED));
53
+ if (options === undefined || junit.length > 1 || changed > 1) return undefined;
54
+ if (changed === 1 && (options.tier !== undefined || options.full === true)) return undefined;
55
+ return { options, junit: Option.fromNullishOr(junit[0]), changed: changed === 1 };
87
56
  }
88
57
 
89
58
  function matches(declaration: SkipDeclaration, result: TestResult): boolean {
@@ -176,7 +145,8 @@ export type SuiteSelection =
176
145
  | { readonly kind: "full" }
177
146
  | { readonly kind: "tier"; readonly tier: TestTier }
178
147
  | { readonly kind: "smoke" }
179
- | { readonly kind: "files"; readonly files: readonly string[] };
148
+ | { readonly kind: "files"; readonly files: readonly string[] }
149
+ | { readonly kind: "changed"; readonly e2e: readonly string[] };
180
150
 
181
151
  export function selectSuite(options: TestOptions, environment: Environment): SuiteSelection {
182
152
  if (options.tier !== undefined) return { kind: "tier", tier: options.tier };
@@ -195,6 +165,7 @@ export function smokeList(manifest: unknown): readonly string[] | undefined {
195
165
 
196
166
  export function suiteArgs(selection: SuiteSelection, smoke: readonly string[]): readonly string[] {
197
167
  if (selection.kind === "tier") return tierArgs(selection.tier);
168
+ if (selection.kind === "changed") return suiteArgs({ kind: "smoke" }, selection.e2e);
198
169
  if (selection.kind === "smoke") return ["./tests/unit", ...smoke.map((file) => `./${file}`)];
199
170
  if (selection.kind === "files") return selection.files.map((file) => `./${file}`);
200
171
  return [];
@@ -248,6 +219,10 @@ const narrowSelection = Effect.fn("narrowSelection")(function* (options: TestOpt
248
219
 
249
220
  const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, smoke: readonly string[]) {
250
221
  if (selection.kind === "smoke") return yield* readSmokeDeclarations(process.cwd(), smoke);
222
+ if (selection.kind === "changed") {
223
+ const files = unitTierPlus(yield* scanTestFiles(process.cwd()), selection.e2e);
224
+ return { files, declarations: yield* readDeclarationsIn(process.cwd(), files) };
225
+ }
251
226
  const declarations =
252
227
  selection.kind === "files"
253
228
  ? yield* readDeclarationsIn(process.cwd(), selection.files)
@@ -255,6 +230,16 @@ const readScope = Effect.fn("readScope")(function* (selection: SuiteSelection, s
255
230
  return { files: undefined, declarations };
256
231
  });
257
232
 
233
+ const changedSelection = Effect.fn("changedSelection")(function* () {
234
+ const e2e = yield* changedE2eFiles(process.cwd());
235
+ yield* Console.log(
236
+ e2e.length === 0
237
+ ? `${NAME}: the change touches no e2e test files, so only the unit tier runs`
238
+ : `${NAME}: the change touches ${e2e.length} e2e test file(s): ${e2e.join(" ")}`,
239
+ );
240
+ return { kind: "changed", e2e } satisfies SuiteSelection;
241
+ });
242
+
258
243
  const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonly string[], workers: number | undefined) {
259
244
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
260
245
  const command = ["test", "--randomize", `--parallel=${workerCount(workers)}`, ...args, ...reporterArgs(outfile)];
@@ -272,17 +257,23 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, args: readonl
272
257
  const testEntry = Effect.gen(function* () {
273
258
  const entry = entryOptions(process.argv.slice(2));
274
259
  if (entry === undefined) return yield* new Usage({ message: USAGE });
275
- const { options, junit } = entry;
260
+ const { options, junit, changed } = entry;
276
261
  const fs = yield* FileSystem.FileSystem;
277
262
  const path = yield* Path.Path;
278
263
  const environment = yield* environmentOf;
279
- const selection = yield* narrowSelection(options, selectSuite(options, environment), environment);
264
+ const selection = changed
265
+ ? yield* changedSelection()
266
+ : yield* narrowSelection(options, selectSuite(options, environment), environment);
280
267
  if (selection.kind === "files" && selection.files.length === 0) {
281
268
  yield* Console.log(`${NAME}: the pull request changes no file a test reads, so no test ran`);
282
269
  return true;
283
270
  }
284
271
  const smoke = selection.kind === "smoke" ? yield* readSmokeList(process.cwd()) : [];
285
272
  const scope = yield* readScope(selection, smoke);
273
+ if (scope.files?.length === 0 && selection.kind === "changed") {
274
+ yield* Console.log(`${NAME}: no unit test and no e2e test the change touches, so no test ran`);
275
+ return true;
276
+ }
286
277
  if (scope.files?.length === 0) {
287
278
  yield* Console.log(`${NAME}: no unit test and no smoke list, so this local run ran nothing; declare checks.smoke or pass ${FULL}`);
288
279
  return true;
@@ -3,7 +3,7 @@ import { Config, Console, Context, Effect, FileSystem, Layer, Option, Path, Sche
3
3
  import { ChildProcessSpawner } from "effect/process";
4
4
  import { DEFAULT_BRANCH } from "../core/gates.ts";
5
5
  import { runMain, Usage } from "../core/main.ts";
6
- import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../delivery/github.ts";
6
+ import { gh, type GitHubFailure, gitHubJson, REPOSITORY } from "../core/github.ts";
7
7
  import { parseReport as parseMutationReport, type ReportFile } from "./mutation-compare.ts";
8
8
  import { cleared, type Finding, findingsOf, gatesDecided, gatesOf, keyOf, WholeTreeRecordSchema, noted, type Observed, type WholeTreeRecord, percent, type PreviousRun, scoresOf, type TestReport } from "./whole-tree-run-findings.ts";
9
9
  import { parseReport as parseTestReport } from "./test-report.ts";