@avi2dg/checks 0.42.0 → 0.44.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -2
  3. package/{commitlint.config.js → commitlint.config.ts} +1 -1
  4. package/dist/mutation/mutation-guard-plugin.js +40 -0
  5. package/dist/mutation/mutation-refusal.js +23 -0
  6. package/docs/configs/commit-messages.md +2 -2
  7. package/docs/design.md +20 -0
  8. package/docs/gates/checks-ci-wiring.md +3 -2
  9. package/docs/gates/checks-copy.md +113 -0
  10. package/docs/gates/checks-docs-only.md +3 -2
  11. package/docs/gates/checks-docs.md +3 -0
  12. package/docs/gates/checks-flake.md +49 -1
  13. package/docs/gates/checks-lint.md +2 -1
  14. package/docs/gates/checks-mutation-baseline.md +4 -2
  15. package/docs/gates/checks-mutation-compare.md +33 -8
  16. package/docs/gates/checks-mutation-scope.md +140 -0
  17. package/docs/gates/checks-mutation.md +11 -7
  18. package/docs/gates/checks-no-plain-js.md +60 -0
  19. package/docs/gates/checks-test-layout.md +3 -2
  20. package/docs/gates/checks-test.md +35 -6
  21. package/package.json +18 -6
  22. package/src/core/declared-inputs.ts +76 -0
  23. package/src/core/gates.ts +4 -0
  24. package/src/core/git.ts +9 -0
  25. package/src/delivery/ci-wiring.ts +25 -11
  26. package/src/delivery/commit-title.ts +2 -2
  27. package/src/docs/copy-html.ts +110 -0
  28. package/src/docs/copy-text.ts +70 -0
  29. package/src/docs/copy.ts +70 -0
  30. package/src/docs/prose-matchers.ts +14 -14
  31. package/src/quality/frontend-syntax.ts +9 -52
  32. package/src/quality/no-plain-js.ts +71 -0
  33. package/src/testing/flake-preload.ts +4 -0
  34. package/src/testing/flake.ts +142 -16
  35. package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
  36. package/src/testing/mutation-scope.ts +354 -0
  37. package/src/testing/mutation.ts +1 -1
  38. package/src/testing/test-layout.ts +8 -15
  39. package/src/testing/test-report.ts +20 -5
  40. package/src/testing/test-skips.ts +1 -1
  41. package/src/testing/test.ts +53 -11
  42. package/stryker.preset.js +3 -3
  43. package/src/testing/mutation-guard-plugin.js +0 -13
@@ -0,0 +1,140 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-mutation-scope
6
+
7
+ `checks-mutation-scope` turns a pull request's diff into the files its mutation runs mutate, and prints them as lines for `GITHUB_ENV`.
8
+
9
+ ## What it checks
10
+
11
+ It picks a scope rather than judging one, so it fails only when it cannot pick one.
12
+ It reads `git diff --name-status -M` from the merge base of `<base>` and `HEAD` to `HEAD`.
13
+ A changed source enters the scope when the repository's own Stryker `mutate` globs match it, with each negated glob applied in order.
14
+ It resolves each glob and each path against the working directory, as Stryker does, so an absolute glob matches too.
15
+ A `mutate` entry with a mutation range, such as `src/a.ts:1-10`, fails the run, since a scope of whole files cannot keep the range.
16
+ `--mutate` replaces those globs, so a path the config excludes, such as `packages/*/src/live/**` or `packages/*/src/bin.ts`, stays out of the scope.
17
+ A renamed source enters under both names, and a deleted source does not enter.
18
+
19
+ A unit test is a `.test.ts` or `.test.tsx` file under `tests/unit/`, the files the preset's `unitTestFiles` lists.
20
+ A unit test the pull request adds, edits, deletes or renames is changed.
21
+ A unit test that reads a changed file under `tests/` is changed too.
22
+ It reads a file when it imports it, directly or through another file under `tests/`.
23
+ An import resolves as Bun resolves it, to the file it names, then with an extension added, and only then to the directory's `index`.
24
+ A unit test also reads a changed fixture under `tests/fixtures/` when it names the fixture's path or a directory above it.
25
+ Bun loads each `[test] preload` in `bunfig.toml` before every test file.
26
+ So every unit test is changed when the preload list differs between the merge base and `HEAD`.
27
+ The same holds when the pull request changes a preload or a file a preload imports, wherever that file lives.
28
+
29
+ Each source whose mutants a changed unit test covers or kills in the full baseline report then enters the scope, when it still exists at `HEAD`.
30
+ So a kill lost through a test change alone still shows in the comparison, in a source the pull request leaves alone.
31
+ A changed unit test with no baseline report fails the run rather than reading incomplete coverage.
32
+ Stryker records each test file's source in the report's `testFiles`.
33
+ A changed unit test the merge base holds must have that recorded source match its content at the merge base.
34
+ Otherwise the report ran an older test, so its coverage cannot say what the test covers now.
35
+ The scope then widens to every file at `HEAD` the `mutate` globs match.
36
+ A unit test the pull request adds is new to every report, so it needs no entry.
37
+ Stryker writes no source without mutants into the report, so a mutated source the report lacks may be newer than it.
38
+ When a unit test changed, each such source that exists at `HEAD` enters the scope.
39
+ A change under `tests/e2e/` changes no unit test, so it needs no baseline report.
40
+ The scope trusts the coverage edges of the latest full baseline.
41
+ So a helper, fixture or preload changed on `main` since that baseline can hide a lost kill until the scheduled full baseline refreshes.
42
+
43
+ ## What it reads
44
+
45
+ - The diff from the merge base to `HEAD`, and the files git tracks at both.
46
+ - The Stryker config file `stryker run` loads from the working directory, such as `stryker.conf.mjs`, for its `mutate` globs.
47
+ It imports a JavaScript config, so the step runs after `bun install`.
48
+ With no config file, or a config with no `mutate`, it takes Stryker's default globs.
49
+ - `bunfig.toml` at the merge base and at `HEAD`, for its `[test] preload` entries.
50
+ - Each preload at `HEAD`, and each file it imports through a relative path, for the files the preloads load.
51
+ - Each tracked `.ts` and `.tsx` file under `tests/` from the working tree, for its relative imports and the fixture paths it names.
52
+ - Each changed unit test at the merge base, to match it against the source the report recorded.
53
+ - The full baseline report that `checks-mutation-baseline --full` restores, for each mutant's `coveredBy` and `killedBy` and the `testFiles` that own those tests.
54
+
55
+ Stryker leaves `coveredBy` empty on a static mutant, so only `killedBy` names its tests.
56
+ The report must come from a run that restored no state and passed `--disableBail`, which lists every killing test.
57
+
58
+ ## Arguments
59
+
60
+ ```sh
61
+ checks-mutation-scope <base> [<full-baseline-report>]
62
+ ```
63
+
64
+ `<base>` is the branch the pull request merges into, such as `origin/main`, or the merge base itself.
65
+ `<full-baseline-report>` is the path the full baseline was restored to, and a path with no file reads as no report.
66
+
67
+ ## Output
68
+
69
+ It prints one line for each list, as `NAME=a,b`, with each list sorted by path:
70
+
71
+ | Line | What it lists |
72
+ | --- | --- |
73
+ | `SCOPE` | the files the base and head runs both mutate, empty when nothing in mutation scope changed |
74
+ | `STALE_TESTS` | the unit tests that count as changed only because they read a changed file |
75
+
76
+ Stryker reads each `--mutate` entry as a glob and turns each backslash into a `/`.
77
+ So `SCOPE` wraps each glob character in a path in a class of its own.
78
+ `app/routes/[id].ts` prints as `app/routes/[[]id[]].ts`, which matches that file and not `app/routes/i.ts`.
79
+ `STALE_TESTS` names each test by its literal path, as the incremental state keys it.
80
+
81
+ Stryker compares only a test file's own source when it resumes from incremental state.
82
+ A run that resumes from the merge base's state drops each `STALE_TESTS` entry from that state first, so Stryker retests the mutants those tests cover.
83
+
84
+ On stderr it names each unit test that reads a changed file, and each source a changed unit test brings in.
85
+ It also names each stale test that widens the scope.
86
+
87
+ ## Exit codes
88
+
89
+ | Code | When |
90
+ | --- | --- |
91
+ | 0 | it printed the scope, an empty one included |
92
+ | 2 | the arguments do not parse, git fails, the Stryker config, `bunfig.toml` or the baseline report does not load, a `mutate` entry names a mutation range, or a unit test changed with no baseline report |
93
+
94
+ ## Sample output
95
+
96
+ A helper change that reaches a source the pull request leaves alone:
97
+
98
+ ```
99
+ mutation-scope: tests/unit/dashboard/snapshot.test.ts reads tests/lib/session-fixture.ts
100
+ mutation-scope: packages/ui/src/layout.ts enters through tests/unit/dashboard/snapshot.test.ts
101
+ SCOPE=packages/session-quota/src/notes.ts,packages/ui/src/layout.ts
102
+ STALE_TESTS=tests/unit/dashboard/snapshot.test.ts
103
+ ```
104
+
105
+ A changed unit test with no full baseline on `main`, on stderr:
106
+
107
+ ```
108
+ mutation-scope: tests/unit/dashboard/snapshot.test.ts changed, but no baseline report at /runner/temp/baseline-full/mutation.json says which sources they cover
109
+ ```
110
+
111
+ A changed unit test that `main` edited after its newest full baseline, on stderr:
112
+
113
+ ```
114
+ mutation-scope: the baseline report did not run tests/unit/dashboard/snapshot.test.ts as the merge base holds it, so every mutated source enters
115
+ ```
116
+
117
+ ## When it runs
118
+
119
+ Only a CI step the repository writes runs it, in the pull request's mutation comparison, before either Stryker run:
120
+
121
+ ```yaml
122
+ - name: Select mutation scope
123
+ env:
124
+ GH_TOKEN: ${{ github.token }}
125
+ run: |
126
+ base="$(git merge-base HEAD "origin/$GITHUB_BASE_REF")"
127
+ echo "BASE_SHA=$base" >> "$GITHUB_ENV"
128
+ bunx checks-mutation-baseline --full "$RUNNER_TEMP/baseline-full/mutation.json"
129
+ bunx checks-mutation-scope "$base" "$RUNNER_TEMP/baseline-full/mutation.json" >> "$GITHUB_ENV"
130
+ ```
131
+
132
+ Each later step reads `$SCOPE` from the environment, and a failed scope step adds nothing to it.
133
+ [checks-mutation-compare](checks-mutation-compare.md#running-it-in-ci) shows the whole job.
134
+ The full baseline comes from the `mutation` workflow, as [checks-mutation](checks-mutation.md#running-it-in-ci) shows.
135
+
136
+ ## Related topics
137
+
138
+ - [checks-mutation-compare](checks-mutation-compare.md)
139
+ - [checks-mutation](checks-mutation.md)
140
+ - [checks-mutation-baseline](checks-mutation-baseline.md)
@@ -96,17 +96,20 @@ jobs:
96
96
  with:
97
97
  bun-version-file: .bun-version
98
98
  - run: bun install --frozen-lockfile
99
- - run: bunx stryker run
100
- - if: always()
101
- run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/report/mutation.json"
99
+ - run: bunx stryker run --disableBail
100
+ - run: bunx checks-mutation-baseline --slim reports/mutation/mutation.json "$RUNNER_TEMP/full/mutation.json"
102
101
  - uses: actions/upload-artifact@v4
103
- if: always()
104
102
  with:
105
- name: mutation-report
106
- path: ${{ runner.temp }}/report/mutation.json
107
- retention-days: 7
103
+ name: mutation-baseline-full
104
+ path: ${{ runner.temp }}/full/mutation.json
105
+ retention-days: 30
108
106
  ```
109
107
 
108
+ The run restores no state and passes `--disableBail`, so its report names every test that covers or kills each mutant.
109
+ The upload is the `mutation-baseline-full` artifact that a pull request's scope step restores with `checks-mutation-baseline --full`.
110
+ [checks-mutation-scope](checks-mutation-scope.md) reads it to bring in the sources a changed unit test covers or kills.
111
+ The restore reads only a successful run of `mutation.yml` on `main`, so a sweep that fails publishes nothing a pull request reads.
112
+ The artifact keeps 30 days, so the newest full baseline outlives the week between full runs.
110
113
  The upload holds the report without each mutant's `statusReason`, as [checks-mutation-baseline](checks-mutation-baseline.md#the-saved-baselines) says, since the full report of a large repository passes 100 MB as an artifact.
111
114
 
112
115
  The job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a repository sends its full sweeps.
@@ -118,4 +121,5 @@ GitHub sets `CI=true` on every runner, so the preset lets the full run through t
118
121
  ## Related topics
119
122
 
120
123
  - [checks-mutation-compare](checks-mutation-compare.md)
124
+ - [checks-mutation-scope](checks-mutation-scope.md)
121
125
  - [checks-subsumed-tests](checks-subsumed-tests.md)
@@ -0,0 +1,60 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-no-plain-js
6
+
7
+ `checks-no-plain-js` is the gate that refuses a plain JavaScript source file in a repository that tracks TypeScript.
8
+
9
+ ## What it checks
10
+
11
+ It fails on every `.js` or `.mjs` file in the working tree that none of the exemptions below covers.
12
+ Each line names the file and tells its writer to convert it to TypeScript.
13
+ `dist/` holds the committed build output, so the check passes over it.
14
+ `dependency-cruiser.config.js` at the repository root is written by `bun run build` from the TypeScript builder, so the check passes over that one path.
15
+ `node_modules/` and `repos/` hold installed and vendored trees, so the check passes over both.
16
+ `public/` holds assets the server sends verbatim, which no compiler reads, so the check passes over it.
17
+ `tests/fixtures/` holds data the suite never parses, so the check passes over it.
18
+ Stryker loads its config only as JSON or JavaScript, so the check passes over a config file carrying a Stryker config name, such as `stryker.conf.mjs`.
19
+ The Stryker preset ships as `stryker.preset.js` under its documented import path, so the check passes over that name.
20
+ A JavaScript config whose tool also loads TypeScript fails, such as a commitlint or dependency-cruiser config.
21
+
22
+ ## What it reads
23
+
24
+ It reads the working tree.
25
+ It scans the tracked and untracked files that `git ls-files --exclude-standard` reports, so `node_modules/` and every gitignored tree are out of reach.
26
+ A local run agrees with CI before `git add`.
27
+
28
+ ## Arguments
29
+
30
+ ```sh
31
+ checks-no-plain-js [<directory>]
32
+ ```
33
+
34
+ It checks the directory it runs in, or the directory it is given.
35
+
36
+ ## Exit codes
37
+
38
+ | Code | When |
39
+ | --- | --- |
40
+ | 0 | the working tree holds no plain JavaScript source |
41
+ | 1 | a file in the working tree is plain JavaScript |
42
+ | 2 | git does not list the working tree |
43
+
44
+ ## Sample output
45
+
46
+ ```
47
+ no-plain-js: 2 violation(s)
48
+ src/legacy.js: plain JavaScript source in a TypeScript repository; convert it to TypeScript
49
+ tools/build.mjs: plain JavaScript source in a TypeScript repository; convert it to TypeScript
50
+ ```
51
+
52
+ ## When it runs
53
+
54
+ `checks-lint` runs it when the repository tracks TypeScript.
55
+
56
+ ## Related topics
57
+
58
+ - [checks-lint](checks-lint.md)
59
+ - [checks-test-layout](checks-test-layout.md)
60
+ - [checks-unused](checks-unused.md)
@@ -20,12 +20,13 @@ It fails unless the repository holds this shape, and names the file and the path
20
20
  Bun ignores both directories in the default suite.
21
21
  A live tier with test files requires `test:live` set to `checks-test --tier=live`.
22
22
  A pixel tier with test files requires `test:pixel` set to `checks-test --tier=pixel`.
23
+ Either tier script may add `--parallel=<workers>`.
23
24
  - A test runs in-process or in a process of its own.
24
25
  A test outside the `e2e`, `live` and `pixel` levels runs in-process, quarantined or not, so it may not import `node:child_process`, `net`, `http`, `https`, `http2`, `tls` or `dgram`.
25
26
  It may not import `$`, `spawn`, `spawnSync`, `connect`, `serve` or `listen` from `bun`, may not touch `Bun.$` or `Bun.spawn`, and may not call `fetch`.
26
27
  A test at the `e2e`, `live` or `pixel` level may do all of it, under `tests/quarantine/` as well.
27
28
  Helpers in `tests/lib/**` answer to the same rule, since an in-process test reaches them.
28
- - `scripts.test` is exactly `checks-test`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
+ - `scripts.test` is `checks-test`, optionally with `--parallel=<workers>`, which runs `bun test --randomize` as [checks-test](checks-test.md) says.
29
30
  - `scripts.lint` runs this check, itself or through `checks-lint` called by its bare bin name.
30
31
  - `bunfig.toml` carries every `[test]` key of the shipped preset with the same value.
31
32
  `[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.
@@ -78,7 +79,7 @@ It checks the directory it runs in, or the directory it is given.
78
79
  ```
79
80
  test-layout: 4 violation(s)
80
81
  src/a.test.ts: a test file must live at tests/<level>/**/*.test.ts, or tests/quarantine/<level>/**/*.test.ts while quarantined, with unit, e2e, live, or pixel as the level; move it to tests/unit/a.test.ts
81
- package.json: scripts.test must be exactly "checks-test", which runs bun test --randomize and judges its skips, found "bun test"
82
+ package.json: scripts.test must be "checks-test", optionally with --parallel=<workers>, which runs bun test --randomize and judges its skips, found "bun test"
82
83
  package.json: scripts.lint must run the layout check: add "checks-lint"
83
84
  bunfig.toml: bunfig.toml is missing; bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml
84
85
  ```
@@ -47,9 +47,11 @@ A skipped test or a todo without `skipReason` fails in every environment.
47
47
  It does not read a skip list from `package.json`.
48
48
  It counts a run as `ci` when `CI` is true and as `local` otherwise.
49
49
 
50
- The command takes no arguments for the default suite.
50
+ The command takes no arguments for the default suite, except the worker count of [Parallel runs](#parallel-runs).
51
51
  Use `checks-test --tier=live` or `checks-test --tier=pixel` for a named test tier.
52
- A tier run clears Bun's ignored paths, runs only `./tests/live` or `./tests/pixel`, and still checks each skip.
52
+ Use `checks-test --tier=unit` or `checks-test --tier=e2e` to run only one level of the default suite.
53
+ A live or pixel run clears Bun's ignored paths, runs only `./tests/live` or `./tests/pixel`, and still checks each skip.
54
+ A unit or e2e run keeps Bun's ignored paths, runs only `./tests/unit` or `./tests/e2e`, and still checks each skip.
53
55
 
54
56
  Files under `tests/quarantine/` are not run or judged, as [checks-test-layout](checks-test-layout.md) says.
55
57
 
@@ -72,10 +74,37 @@ Add the matching package scripts when either directory contains tests:
72
74
  Bun ignores both directories during the default run.
73
75
  `checks-test-layout` requires each tier script when its directory contains a test file.
74
76
 
77
+ ## Parallel runs
78
+
79
+ A repository runs its test files in parallel by passing a worker count in its `test` script:
80
+
81
+ ```json
82
+ {
83
+ "scripts": {
84
+ "test": "checks-test --parallel=4"
85
+ }
86
+ }
87
+ ```
88
+
89
+ `checks-test --parallel=<workers>` runs `bun test --randomize --parallel=<workers>`, so Bun hands each test file to one of that many worker processes.
90
+ Each file then runs in a fresh global, so a file cannot lean on state another file left behind.
91
+ The count is a whole number from 1 up, and without the option the suite runs serially in one process.
92
+ Tests still run in random order, and `test.only` still fails the run.
93
+ Bun merges every worker's results into the one JUnit report, so each skipped test and each todo is judged as in a serial run.
94
+ A tier script takes the same option, as in `checks-test --tier=live --parallel=4`.
95
+ [checks-flake](checks-flake.md#parallel-hunts) hunts with the worker count the `test` script sets.
96
+
97
+ A file whose worker crashes, for example when a test calls `process.exit`, fails the run.
98
+ Bun reports no result from that file, so its skips are not judged and its declarations are not reported as stale.
99
+
100
+ `checks-test` takes no timings file, because Bun ignores the order a timings file sets whenever `--randomize` is on.
101
+ A timings option waits for Bun to honour timings under random order.
102
+
75
103
  ## Arguments
76
104
 
77
- The default command takes no arguments.
78
- Its named tier options are `--tier=live` and `--tier=pixel`.
105
+ The default command takes no arguments except `--parallel=<workers>`, as [Parallel runs](#parallel-runs) says.
106
+ Its named tier options are `--tier=live`, `--tier=pixel`, `--tier=unit` and `--tier=e2e`, and a tier run takes `--parallel=<workers>` too.
107
+ Each option appears at most once.
79
108
  It refuses test filters because every test excluded by a filter would look skipped.
80
109
 
81
110
  ## Exit codes
@@ -83,8 +112,8 @@ It refuses test filters because every test excluded by a filter would look skipp
83
112
  | Code | When |
84
113
  | --- | --- |
85
114
  | 0 | every test passed and every skip has a matching site reason |
86
- | 1 | a test failed, a skip lacks a site reason or a declaration is stale in CI |
87
- | 2 | a declaration cannot be read, `CI` is not a boolean or Bun wrote no report |
115
+ | 1 | a test failed, a parallel worker crashed, a skip lacks a site reason or a declaration is stale in CI |
116
+ | 2 | an argument is not one it takes, a declaration cannot be read, `CI` is not a boolean or Bun wrote no report |
88
117
 
89
118
  ## Sample output
90
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,13 +17,14 @@
17
17
  "docs/design.md",
18
18
  "docs/gates/",
19
19
  "bunfig.toml",
20
- "commitlint.config.js",
20
+ "commitlint.config.ts",
21
21
  "dependency-cruiser.config.js",
22
22
  "knip-base.json",
23
23
  "src/quality/lint-coverage.sh",
24
24
  "src/core/main.ts",
25
25
  "src/core/git.ts",
26
26
  "src/core/gates.ts",
27
+ "src/core/declared-inputs.ts",
27
28
  "src/core/lint.ts",
28
29
  "src/delivery/changelog.ts",
29
30
  "src/delivery/changelog-write.ts",
@@ -39,13 +40,14 @@
39
40
  "src/core/swc.ts",
40
41
  "src/testing/test-report.ts",
41
42
  "src/testing/flake.ts",
43
+ "src/testing/flake-preload.ts",
42
44
  "src/delivery/commit-identity.ts",
43
45
  "src/delivery/commit-title.ts",
44
46
  "src/delivery/docs-only.ts",
45
47
  "src/testing/mutation.ts",
46
- "src/testing/mutation-scope.js",
47
- "src/testing/mutation-guard-plugin.js",
48
+ "src/testing/mutation-refusal.ts",
48
49
  "src/testing/mutation-compare.ts",
50
+ "src/testing/mutation-scope.ts",
49
51
  "src/testing/mutation-baseline.ts",
50
52
  "src/testing/mutation-baseline-saved.ts",
51
53
  "src/testing/subsumed-tests.ts",
@@ -71,6 +73,9 @@
71
73
  "src/testing/quarantine-clock.ts",
72
74
  "src/docs/sentence-length.ts",
73
75
  "src/docs/docs.ts",
76
+ "src/docs/copy.ts",
77
+ "src/docs/copy-text.ts",
78
+ "src/docs/copy-html.ts",
74
79
  "src/dependencies/vendor.ts",
75
80
  "src/dependencies/vendor-args.ts",
76
81
  "src/dependencies/cache-root.ts",
@@ -100,6 +105,7 @@
100
105
  "dist/effect-channel/index.js",
101
106
  "dist/readability/index.js",
102
107
  "dist/data-shape/index.js",
108
+ "dist/mutation/",
103
109
  "dist/presets/"
104
110
  ],
105
111
  "exports": {
@@ -142,6 +148,7 @@
142
148
  "checks-mutation": "src/testing/mutation.ts",
143
149
  "checks-mutation-compare": "src/testing/mutation-compare.ts",
144
150
  "checks-mutation-baseline": "src/testing/mutation-baseline.ts",
151
+ "checks-mutation-scope": "src/testing/mutation-scope.ts",
145
152
  "checks-subsumed-tests": "src/testing/subsumed-tests.ts",
146
153
  "checks-ci-wiring": "src/delivery/ci-wiring.ts",
147
154
  "checks-comment-gate": "src/quality/comment-gate.ts",
@@ -151,20 +158,24 @@
151
158
  "checks-exports": "src/complexity/exports.ts",
152
159
  "checks-quarantine-clock": "src/testing/quarantine-clock.ts",
153
160
  "checks-docs": "src/docs/docs.ts",
161
+ "checks-copy": "src/docs/copy.ts",
154
162
  "checks-vendor": "src/dependencies/vendor.ts",
155
163
  "checks-advisories": "src/dependencies/advisories.ts",
156
164
  "checks-secrets": "src/delivery/secrets.ts",
157
165
  "checks-frontend-syntax": "src/quality/frontend-syntax.ts",
166
+ "checks-no-plain-js": "src/quality/no-plain-js.ts",
158
167
  "checks-browser": "src/quality/browser/browser.ts",
159
168
  "checks-imports": "src/dependencies/imports.ts",
160
169
  "checks-effect-scope": "src/quality/effect-scope.ts"
161
170
  },
162
171
  "scripts": {
163
172
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
164
- "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
173
+ "build": "bun build src/quality/effect-channel/index.ts --outdir dist/effect-channel --target node --format esm && bun build src/complexity/readability/index.ts --outdir dist/readability --target node --format esm && bun build src/quality/data-shape/index.ts --outdir dist/data-shape --target node --format esm && bun build src/testing/mutation-guard-plugin.ts src/testing/mutation-refusal.ts --outdir dist/mutation --target node --format esm && bun build src/quality/presets/oxlint.ts src/quality/presets/knip.ts src/quality/presets/dependency-cruiser.ts --outdir dist/presets --target node --format esm && tsc -p tsconfig.presets.json && bun scripts/configs-write.ts && bun src/quality/effect-scope.ts && bun scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
165
174
  "lint": "oxlint && bun src/core/lint.ts",
166
175
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
167
176
  "test": "bun src/testing/test.ts",
177
+ "test:unit": "bun src/testing/test.ts --tier=unit",
178
+ "test:e2e": "bun src/testing/test.ts --tier=e2e --parallel=4",
168
179
  "mutate": "bunx stryker run",
169
180
  "mutate:incremental": "bunx stryker run --incremental"
170
181
  },
@@ -227,7 +238,8 @@
227
238
  "@effect/platform-node-shared": "4.0.0",
228
239
  "@total-typescript/ts-reset": "0.6.1",
229
240
  "commonmark": "0.31.2",
230
- "knip": "6.38.0"
241
+ "knip": "6.38.0",
242
+ "minimatch": "10.2.6"
231
243
  },
232
244
  "overrides": {
233
245
  "qs": "6.16.0",
@@ -0,0 +1,76 @@
1
+ import type { BunServices } from "@effect/platform-bun";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { runMain, Usage } from "./main.ts";
4
+
5
+ export type Declared = {
6
+ readonly name: string;
7
+ readonly file: string;
8
+ readonly declares: string;
9
+ };
10
+
11
+ export type Input = {
12
+ readonly glob: string;
13
+ readonly files: readonly string[];
14
+ };
15
+
16
+ export type Violation = {
17
+ readonly file: string;
18
+ readonly line: number;
19
+ readonly column: number;
20
+ readonly text: string;
21
+ };
22
+
23
+ export type Verdict<S> = {
24
+ readonly report: (scan: S) => string;
25
+ readonly passes: (scan: S) => boolean;
26
+ };
27
+
28
+ const Declaration = Schema.Struct({ inputs: Schema.NonEmptyArray(Schema.NonEmptyString) });
29
+
30
+ const decodeDeclaration = Schema.decodeUnknownEffect(Schema.fromJsonString(Declaration));
31
+
32
+ const readInputs = Effect.fn("readInputs")(function* (root: string, { file, declares }: Declared) {
33
+ const fs = yield* FileSystem.FileSystem;
34
+ const path = (yield* Path.Path).join(root, file);
35
+ if (!(yield* fs.exists(path))) {
36
+ return yield* new Usage({ message: `${file} is missing, and a repository opts in to this gate by declaring its ${declares} there` });
37
+ }
38
+ const { inputs } = yield* decodeDeclaration(yield* fs.readFileString(path)).pipe(
39
+ Effect.mapError(({ message }) => new Usage({ message: `${file} does not decode: ${message}` })),
40
+ );
41
+ return inputs;
42
+ });
43
+
44
+ export function emptyInputs(inputs: readonly Input[]): readonly Input[] {
45
+ return inputs.filter(({ files }) => files.length === 0);
46
+ }
47
+
48
+ export function violationLine({ file, line, column, text }: Violation): string {
49
+ return `${file}:${line}:${column} ${text}`;
50
+ }
51
+
52
+ export function inputsReport(name: string, inputs: readonly Input[], problems: readonly string[]): string {
53
+ const files = new Set(inputs.flatMap((input) => input.files)).size;
54
+ const scanned = `${files} file(s) from ${inputs.length} declared input(s)`;
55
+ if (problems.length === 0) return `${name}: no violation in ${scanned}`;
56
+ return [`${name}: ${problems.length} problem(s) in ${scanned}:`, ...problems.map((problem) => ` ${problem}`)].join("\n");
57
+ }
58
+
59
+ const declaredGate = Effect.fn("declaredGate")(function* <S, E>(
60
+ declared: Declared,
61
+ scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
62
+ { report, passes }: Verdict<S>,
63
+ ) {
64
+ const root = (yield* Path.Path).resolve(process.argv[2] ?? ".");
65
+ const scan = yield* scanInputs(root, yield* readInputs(root, declared));
66
+ yield* Console.log(report(scan));
67
+ return passes(scan);
68
+ });
69
+
70
+ export function runDeclaredGate<S, E extends { readonly message: string }>(
71
+ declared: Declared,
72
+ scanInputs: (root: string, globs: readonly string[]) => Effect.Effect<S, E, BunServices.BunServices>,
73
+ verdict: Verdict<S>,
74
+ ): void {
75
+ runMain(declared.name, declaredGate(declared, scanInputs, verdict));
76
+ }
package/src/core/gates.ts CHANGED
@@ -40,6 +40,8 @@ const OXLINT_CONFIG: TrackedContent = { pathspecs: ["oxlint.config.ts"], content
40
40
 
41
41
  const FRONTEND_SYNTAX_DECLARATION: TrackedContent = { pathspecs: ["frontend-syntax.json"], content: "a frontend syntax declaration" };
42
42
 
43
+ const COPY_DECLARATION: TrackedContent = { pathspecs: ["copy.json"], content: "a copy declaration" };
44
+
43
45
  export const KIT_GATES = [
44
46
  { bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
45
47
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -47,10 +49,12 @@ export const KIT_GATES = [
47
49
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
48
50
  { bin: "checks-effect-scope", vector: "quality", file: "effect-scope.ts", reads: "tree", args: ["--check"], appliesTo: OXLINT_CONFIG },
49
51
  { bin: "checks-frontend-syntax", vector: "quality", file: "frontend-syntax.ts", reads: "tree", appliesTo: FRONTEND_SYNTAX_DECLARATION },
52
+ { bin: "checks-no-plain-js", vector: "quality", file: "no-plain-js.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
50
53
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
51
54
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
52
55
  { bin: "checks-secrets", vector: "delivery", file: "secrets.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
53
56
  { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
57
+ { bin: "checks-copy", vector: "docs", file: "copy.ts", reads: "tree", appliesTo: COPY_DECLARATION },
54
58
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
55
59
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: LINTED_SOURCE },
56
60
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
package/src/core/git.ts CHANGED
@@ -29,6 +29,15 @@ export const collect = Effect.fn("collect")(
29
29
  Effect.scoped,
30
30
  );
31
31
 
32
+ export const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
33
+ const fs = yield* FileSystem.FileSystem;
34
+ const path = yield* Path.Path;
35
+ const listed = yield* git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
36
+ return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), {
37
+ concurrency: "unbounded",
38
+ });
39
+ });
40
+
32
41
  export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
33
42
  const failed = (reason: string): GitFailure => new GitFailure({ message: `git ${args.join(" ")}: ${reason.trim()}` });
34
43
  const { stdout, stderr, exitCode } = yield* collect("git", args, cwd, feed).pipe(
@@ -17,6 +17,7 @@ export type Gate = {
17
17
  readonly command: string;
18
18
  readonly words: Command;
19
19
  readonly alternatives: readonly Command[];
20
+ readonly partition: readonly Command[];
20
21
  };
21
22
 
22
23
  export type Declaration = {
@@ -63,6 +64,8 @@ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError"
63
64
 
64
65
  const WORKFLOWS = ".github/workflows";
65
66
  const SCRIPTED = ["lint", "build", "typecheck", "test"];
67
+ const WHOLE_SUITE = "bun run test";
68
+ const SPLIT_SUITE = ["test:unit", "test:e2e"];
66
69
  const BUILT_TREE = "git diff --exit-code";
67
70
  const TITLE_LINT = "bunx checks-commit-title";
68
71
  // The gate holds until every consumer migrates, so the direct commitlint call still counts.
@@ -196,6 +199,21 @@ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Comman
196
199
  return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
197
200
  }
198
201
 
202
+ type Invocation = Omit<RunStep, "script">;
203
+
204
+ function invocations(commands: readonly { command: string; words: Command }[], steps: readonly RunStep[]): readonly Invocation[] {
205
+ return steps.flatMap(({ location, blocker, script }) => {
206
+ const plain = plainCommand(script);
207
+ if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
208
+ const mentioned = commands.find(({ words }) => mentions(script, words));
209
+ if (mentioned === undefined) return [];
210
+ const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
211
+ return [{ location, blocker: blocker ?? alone }];
212
+ });
213
+ }
214
+
215
+ const runsUnblocked = (invoking: readonly Invocation[]): boolean => invoking.some((step) => step.blocker === undefined);
216
+
199
217
  function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates: readonly KitGate[]): readonly Gap[] {
200
218
  return gates.flatMap((gate) => {
201
219
  const entryPoint = entryPointCommand(gate.words, lintGates);
@@ -204,16 +222,10 @@ function gapsAmong(gates: readonly Gate[], steps: readonly RunStep[], lintGates:
204
222
  ...gate.alternatives.map((words) => ({ command: gate.command, words })),
205
223
  ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
206
224
  ];
207
- const invoking = steps.flatMap(({ location, blocker, script }) => {
208
- const plain = plainCommand(script);
209
- if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
210
- const mentioned = commands.find(({ words }) => mentions(script, words));
211
- if (mentioned === undefined) return [];
212
- const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
213
- return [{ location, blocker: blocker ?? alone }];
214
- });
215
- if (invoking.some((step) => step.blocker === undefined)) return [];
216
- const blocked = invoking.flatMap(({ location, blocker }) =>
225
+ const invoking = invocations(commands, steps);
226
+ const parts = gate.partition.map((words) => invocations([{ command: words.join(" "), words }], steps));
227
+ if (runsUnblocked(invoking) || (parts.length > 0 && parts.every(runsUnblocked))) return [];
228
+ const blocked = [...invoking, ...parts.flat()].flatMap(({ location, blocker }) =>
217
229
  blocker === undefined ? [] : [{ location, blocker }],
218
230
  );
219
231
  return [{ gate: gate.command, ...(entryPoint === undefined ? {} : { entryPoint: entryPoint.join(" ") }), blocked }];
@@ -252,11 +264,13 @@ export function requiredCommands(scripts: readonly string[]): readonly string[]
252
264
  }
253
265
 
254
266
  export function declarationFor(scripts: readonly string[], branch: string): Declaration {
267
+ const tiers = SPLIT_SUITE.every((name) => scripts.includes(name)) ? SPLIT_SUITE.map((name) => ["bun", "run", name]) : [];
255
268
  return {
256
269
  gates: requiredCommands(scripts).map((command) => ({
257
270
  command,
258
271
  words: command.split(" "),
259
272
  alternatives: command === TITLE_LINT ? TITLE_CALLS.map((call) => call.split(" ")) : [],
273
+ partition: command === WHOLE_SUITE ? tiers : [],
260
274
  })),
261
275
  defaultBranch: branch,
262
276
  lintGates: KIT_GATES,
@@ -277,7 +291,7 @@ function readsOverride(runsOn: unknown): boolean {
277
291
  function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
278
292
  const overridden = readsOverride(runsOn);
279
293
  const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
280
- if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; ${pin}`;
294
+ if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its mutation runs off winbox; ${pin}`;
281
295
  if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
282
296
  const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
283
297
  if (hosted || (visibility !== "private" && !overridden)) return undefined;
@@ -23,8 +23,8 @@ const cliOf = Effect.fn("cliOf")(function* () {
23
23
  // The config ships with the kit, so it sits two levels above this file wherever the kit is installed.
24
24
  const configOf = Effect.fn("configOf")(function* () {
25
25
  const file = yield* Effect.try({
26
- try: () => new URL("../../commitlint.config.js", import.meta.url),
27
- catch: () => setup("cannot resolve commitlint.config.js from the installed kit"),
26
+ try: () => new URL("../../commitlint.config.ts", import.meta.url),
27
+ catch: () => setup("cannot resolve commitlint.config.ts from the installed kit"),
28
28
  });
29
29
  return yield* (yield* Path.Path).fromFileUrl(file);
30
30
  });