@avi2dg/checks 0.28.0 → 0.30.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 +21 -0
- package/README.md +3 -2
- package/dist/templates/agents.md +0 -8
- package/docs/design.md +5 -2
- package/docs/gates/checks-docs.md +25 -2
- package/docs/gates/checks-mutation-compare.md +24 -5
- package/docs/gates/checks-mutation.md +98 -0
- package/docs/gates/checks-repetition.md +1 -0
- package/docs/gates/checks-subsumed-tests.md +2 -0
- package/docs/gates/checks-test.md +3 -0
- package/package.json +6 -1
- package/src/complexity/knip.ts +1 -1
- package/src/core/gates.ts +2 -1
- package/src/docs/doc-agents.ts +106 -0
- package/src/docs/doc-references.ts +4 -0
- package/src/docs/doc-rules.ts +75 -5
- package/src/docs/doc-templates.ts +0 -9
- package/src/docs/docs.ts +23 -6
- package/src/testing/mutation-guard-plugin.js +13 -0
- package/src/testing/mutation-scope.js +27 -0
- package/src/testing/mutation.ts +35 -0
- package/src/testing/test.ts +7 -1
- package/stryker.preset.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.30.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-28.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **testing:** refuse full mutation runs outside CI [#106](https://github.com/avi2d/checks/pull/106)
|
|
12
|
+
- **docs:** refuse a decision-record revision link named on one side only [#105](https://github.com/avi2d/checks/pull/105)
|
|
13
|
+
|
|
14
|
+
## 0.29.0
|
|
15
|
+
|
|
16
|
+
Released 2026-09-28.
|
|
17
|
+
|
|
18
|
+
### Breaking changes
|
|
19
|
+
|
|
20
|
+
- **docs:** hold agent files to a 3,000-character router where every entry points [#101](https://github.com/avi2d/checks/pull/101)
|
|
21
|
+
|
|
22
|
+
### Fixes
|
|
23
|
+
|
|
24
|
+
- **testing:** run checks-test with CI=true so focused tests fail locally [#100](https://github.com/avi2d/checks/pull/100)
|
|
25
|
+
|
|
5
26
|
## 0.28.0
|
|
6
27
|
|
|
7
28
|
Released 2026-09-28.
|
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ The table groups the gates by vector, the part of a repository each one judges.
|
|
|
124
124
|
| quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
|
|
125
125
|
| testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
|
|
126
126
|
| testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
|
|
127
|
-
| docs | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
|
|
127
|
+
| docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
|
|
128
128
|
| delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
|
|
129
129
|
| delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
|
|
130
130
|
| dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
|
|
@@ -135,6 +135,7 @@ These bins run on their own:
|
|
|
135
135
|
|
|
136
136
|
- [`checks-test`](docs/gates/checks-test.md) runs the suite as `scripts.test` and refuses a skip without a reason at its test site.
|
|
137
137
|
- [`checks-flake`](docs/gates/checks-flake.md) runs the suite on a schedule and records the seeds a flaky test fails with.
|
|
138
|
+
- [`checks-mutation`](docs/gates/checks-mutation.md) runs Stryker for scoped checks and refuses a full run outside CI.
|
|
138
139
|
- [`checks-mutation-compare`](docs/gates/checks-mutation-compare.md) holds every mutant in a pull request to no regression.
|
|
139
140
|
- [`checks-subsumed-tests`](docs/gates/checks-subsumed-tests.md) lists each test another test subsumes in a mutation run.
|
|
140
141
|
- [`checks-changelog`](docs/gates/checks-changelog.md) writes the pending release into `CHANGELOG.md` from the conventional commits since the last release.
|
|
@@ -174,7 +175,7 @@ Every path is relative to the installed package, `node_modules/@avi2dg/checks/`.
|
|
|
174
175
|
| `src/` | every bin, which a package script calls by its `checks-` name, the modules the bins import, and the Effect rule blocks under `src/quality/presets/` |
|
|
175
176
|
| `dist/` | the compiled oxlint plugins and the doc templates, one template per kind of doc file |
|
|
176
177
|
| `oxlintrc.json` | the oxlint base config `.oxlintrc.json` extends |
|
|
177
|
-
| `stryker.preset.js` | the Stryker mutation-testing preset |
|
|
178
|
+
| `stryker.preset.js` | the Stryker mutation-testing preset, which refuses a full run outside CI |
|
|
178
179
|
| `tsconfig.effect.json` | the tsconfig fragment with the shared compiler options and the Effect language-service block |
|
|
179
180
|
| `ts-reset.d.ts` | the two ts-reset rules `tsconfig.effect.json` lists in `files` |
|
|
180
181
|
|
package/dist/templates/agents.md
CHANGED
|
@@ -7,11 +7,3 @@
|
|
|
7
7
|
<Leave this section out when the lead holds every constraint.>
|
|
8
8
|
|
|
9
9
|
- <A constraint an agent cannot infer from the code, and the file that holds its detail.>
|
|
10
|
-
|
|
11
|
-
## Maintaining this file
|
|
12
|
-
|
|
13
|
-
Keep this file for knowledge useful to almost every future agent session in this project.
|
|
14
|
-
Do not repeat what the codebase already shows.
|
|
15
|
-
Point to the authoritative file or command instead.
|
|
16
|
-
Prefer rewriting or pruning existing entries over appending new ones.
|
|
17
|
-
When updating this file, preserve this bar for all agents and keep entries concise.
|
package/docs/design.md
CHANGED
|
@@ -86,6 +86,7 @@ npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
|
|
|
86
86
|
`bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
|
|
87
87
|
|
|
88
88
|
Each oxlint plugin ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
|
|
89
|
+
`@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`.
|
|
89
90
|
`dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
|
|
90
91
|
No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
|
|
91
92
|
CI runs `git diff --exit-code` over the whole tree after `bun run build`.
|
|
@@ -140,7 +141,8 @@ That flag reports a repeated block as new once its text changes, so a change tha
|
|
|
140
141
|
## checks-test runs the suite itself
|
|
141
142
|
|
|
142
143
|
`checks-test` runs bun itself rather than reading a report that another run left.
|
|
143
|
-
A skip
|
|
144
|
+
A `"ci"` skip gated on a daemon or tool that CI lacks shows in CI's own run, and an earlier run's report may be stale or narrowed.
|
|
145
|
+
When the same resource is also absent locally, the test skips there too, and its `"ci"` declaration leaves that skip undeclared, so the local run fails.
|
|
144
146
|
It reads the JUnit report bun writes to a temporary directory, because bun has no other per-test output meant for a program.
|
|
145
147
|
|
|
146
148
|
## Quarantine has one limit
|
|
@@ -175,7 +177,8 @@ A repository adopts the templates as its files change, and an untouched file is
|
|
|
175
177
|
The prose rules judge only the lines a change adds or edits.
|
|
176
178
|
A report about the past is refused the way a promise about the future is, because history on a living page reads as current fact.
|
|
177
179
|
Text nobody touched never breaks the templates or the prose rules, and a record keeps the words it was written in.
|
|
178
|
-
A repository needs no cleanup pass before the gate runs.
|
|
180
|
+
A repository needs no cleanup pass before the gate runs, except on its agent files.
|
|
181
|
+
The ceiling, the rule against a `## Maintaining this file` section and the entry rule judge every agent file at the head commit, because an agent reads the whole file every session, touched or not.
|
|
179
182
|
Review, not the check, keeps a task heading verb first.
|
|
180
183
|
No word list tells `Test layout` from `Test the layout`, and a check that passes the noun would be worse than none.
|
|
181
184
|
|
|
@@ -13,6 +13,8 @@ It holds each doc file a change touches to the template for its kind.
|
|
|
13
13
|
It lists every other doc file that does not match its template yet, and does not fail on it.
|
|
14
14
|
It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
|
|
15
15
|
It fails when a living doc or an agent file names a path, link or command that does not resolve, and the range added or broke it.
|
|
16
|
+
It fails when an agent file holds more than 3,000 characters or a `## Maintaining this file` section, whatever the range touches, as [Agent files](#agent-files) says.
|
|
17
|
+
It fails when an entry in an agent file names no tracked path, link or `bun run` command, whatever the range touches.
|
|
16
18
|
It fails when a living doc or an agent file names a code span the range removed from every file outside the docs, on any line.
|
|
17
19
|
[Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
|
|
18
20
|
The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
|
|
@@ -59,6 +61,7 @@ A template decides a file's structure, and the template file itself is the refer
|
|
|
59
61
|
A `Date: YYYY-MM-DD` line follows the title.
|
|
60
62
|
The first word under Status is Proposed, Accepted, Rejected, Deprecated, Superseded or Retired.
|
|
61
63
|
No other record holds its number.
|
|
64
|
+
A Status that amends, narrows or supersedes another record names it, and the other record names it back.
|
|
62
65
|
- A changelog lists its releases newest first, and each opens with a `Released YYYY-MM-DD.` line.
|
|
63
66
|
- A how-to or tutorial page numbers its steps.
|
|
64
67
|
- `CLAUDE.md` is its template word for word.
|
|
@@ -142,6 +145,24 @@ audience: consumers
|
|
|
142
145
|
---
|
|
143
146
|
```
|
|
144
147
|
|
|
148
|
+
## Agent files
|
|
149
|
+
|
|
150
|
+
An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
|
|
151
|
+
A file over 3,000 characters fails.
|
|
152
|
+
Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
|
|
153
|
+
A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
|
|
154
|
+
Each entry names at least one of these, or it fails:
|
|
155
|
+
|
|
156
|
+
- A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
|
|
157
|
+
It resolves from the root or from the file's directory, `./` and `../` included, and a path that ends in `/` or `/.` names a directory, never a file.
|
|
158
|
+
- A Markdown link written `[text](target)` with a destination and a closing parenthesis, and not an image.
|
|
159
|
+
- A `bun run` command.
|
|
160
|
+
|
|
161
|
+
Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
|
|
162
|
+
An entry is any list item a reader sees, the items above the first section included.
|
|
163
|
+
A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
|
|
164
|
+
A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
|
|
165
|
+
|
|
145
166
|
## What it reads
|
|
146
167
|
|
|
147
168
|
It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
|
|
@@ -150,6 +171,7 @@ It reads the lines the range adds or edits from the diff, with renames detected,
|
|
|
150
171
|
It reads the files tracked at both ends of the range, and the `scripts` of each `package.json` a living doc or an agent file sits under.
|
|
151
172
|
It compares each code span a living doc or an agent file names with the text git tracks outside the docs at both ends of the range.
|
|
152
173
|
It reads the repository's own name and its direct dependencies from the root `package.json` at the head commit.
|
|
174
|
+
It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
|
|
153
175
|
A name that an installed direct dependency still holds counts as present.
|
|
154
176
|
From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
|
|
155
177
|
|
|
@@ -167,8 +189,8 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
167
189
|
|
|
168
190
|
| Code | When |
|
|
169
191
|
| --- | --- |
|
|
170
|
-
| 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, and no code span a living doc or an agent file names vanished from every file outside the docs |
|
|
171
|
-
| 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, or the range removes a name a living doc or an agent file still carries |
|
|
192
|
+
| 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, every agent file holds to the ceiling and holds no `## Maintaining this file`, every entry in an agent file names a tracked path, a link or a command, and no code span a living doc or an agent file names vanished from every file outside the docs |
|
|
193
|
+
| 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, an agent file is over the ceiling or holds `## Maintaining this file`, an entry in an agent file names no tracked path, link or command, or the range removes a name a living doc or an agent file still carries |
|
|
172
194
|
| 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
|
|
173
195
|
|
|
174
196
|
## Sample output
|
|
@@ -191,6 +213,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
|
|
|
191
213
|
|
|
192
214
|
`checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
|
|
193
215
|
A repository adopts the templates as its files change, and the prose rules as its lines change, because an untouched file or line never fails those checks.
|
|
216
|
+
It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
|
|
194
217
|
A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
|
|
195
218
|
|
|
196
219
|
## Related topics
|
|
@@ -69,12 +69,18 @@ mutation-compare: REGRESSION (1 mutant(s))
|
|
|
69
69
|
|
|
70
70
|
Only a CI step the repository writes runs it.
|
|
71
71
|
A repository runs it with `--advisory` for its first month, then drops the flag so it blocks.
|
|
72
|
+
A full sweep runs in CI and never on a laptop.
|
|
73
|
+
Start a baseline with `gh workflow run mutation` and keep its report as an artifact.
|
|
74
|
+
The shared preset refuses a full `stryker run` outside CI and names that workflow command instead, as [checks-mutation](checks-mutation.md) says.
|
|
72
75
|
|
|
73
76
|
## Running it in CI
|
|
74
77
|
|
|
75
|
-
It runs on pull requests
|
|
78
|
+
It runs on pull requests from a workflow named `mutation-compare`, comparing the head report against a report built at the merge-base:
|
|
76
79
|
|
|
77
80
|
```yaml
|
|
81
|
+
name: mutation-compare
|
|
82
|
+
on:
|
|
83
|
+
pull_request:
|
|
78
84
|
jobs:
|
|
79
85
|
mutation-compare:
|
|
80
86
|
runs-on: ubuntu-latest
|
|
@@ -86,12 +92,25 @@ jobs:
|
|
|
86
92
|
- run: bun install --frozen-lockfile
|
|
87
93
|
- run: bunx stryker run
|
|
88
94
|
- run: |
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
95
|
+
base_worktree="$RUNNER_TEMP/mutation-base-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
|
|
96
|
+
echo "BASE_WORKTREE=$base_worktree" >> "$GITHUB_ENV"
|
|
97
|
+
git worktree prune
|
|
98
|
+
git worktree add "$base_worktree" "$(git merge-base HEAD origin/main)"
|
|
99
|
+
(cd "$base_worktree" && bun install --frozen-lockfile && bunx stryker run)
|
|
100
|
+
- run: bun run checks-mutation-compare --advisory "$BASE_WORKTREE/reports/mutation/mutation.json" reports/mutation/mutation.json
|
|
101
|
+
- if: always() && env.BASE_WORKTREE != ''
|
|
102
|
+
run: |
|
|
103
|
+
git worktree remove --force "$BASE_WORKTREE"
|
|
104
|
+
git worktree prune
|
|
93
105
|
```
|
|
94
106
|
|
|
107
|
+
Both Stryker runs are full sweeps, and GitHub sets `CI=true` on every runner, so the preset lets them through.
|
|
108
|
+
The base worktree's path carries the run's id and attempt, and the last step removes it even when a run fails, so a runner kept between jobs starts each job clean.
|
|
109
|
+
A public repository keeps `runs-on: ubuntu-latest`, because a pull request from a fork runs its own code on the runner.
|
|
110
|
+
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}` instead.
|
|
111
|
+
With `CI_RUNS_ON` unset, the job then runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
|
|
112
|
+
|
|
95
113
|
## Related topics
|
|
96
114
|
|
|
115
|
+
- [checks-mutation](checks-mutation.md)
|
|
97
116
|
- [checks-test-layout](checks-test-layout.md)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
audience: consumers
|
|
4
|
+
---
|
|
5
|
+
# checks-mutation
|
|
6
|
+
|
|
7
|
+
`checks-mutation` runs Stryker and refuses a full run outside CI.
|
|
8
|
+
|
|
9
|
+
## What it checks
|
|
10
|
+
|
|
11
|
+
It refuses a full mutation run when `CI` is not `true`.
|
|
12
|
+
A full run is one with no `--mutate <glob>` and no `--incremental` flag.
|
|
13
|
+
A `--mutate` without a glob, `--incrementalFile` alone or `--incremental` with `--force` is still a full run.
|
|
14
|
+
Its refusal names `gh workflow run mutation` as the command that starts the same run in CI.
|
|
15
|
+
A run scoped with `--mutate <glob>`, `--mutate=<glob>` or `-m <glob>` stays allowed locally, because pull request comparisons scope to named files.
|
|
16
|
+
An incremental run stays allowed locally only when its incremental report exists, because it reuses the results of mutants that did not change.
|
|
17
|
+
That report is the `incrementalFile` Stryker resolves from the command line and the config file, else `reports/stryker-incremental.json`.
|
|
18
|
+
With no report there, the refusal says to pass `--mutate <glob>`, or to start the full baseline in CI with `gh workflow run mutation`.
|
|
19
|
+
`--help`, `-h` and `--version` are not runs, so they pass straight through to Stryker.
|
|
20
|
+
A laptop with `CI=true` set opts in to a full run on purpose, and the refusal lets it through.
|
|
21
|
+
|
|
22
|
+
The shared Stryker preset makes the same decision from `process.argv` when a `stryker run` loads it.
|
|
23
|
+
So a bare `bunx stryker run` in a repository whose `stryker.conf.mjs` spreads the preset is refused the same way, and `checks-mutation` is a thin wrapper over that decision.
|
|
24
|
+
The preset also registers an ignore plugin that checks the incremental report once Stryker has resolved its options, because a config file can set `incrementalFile` after the preset loads.
|
|
25
|
+
A config that replaces `plugins` or `ignorers` must keep the preset's entries, or that check does not run.
|
|
26
|
+
|
|
27
|
+
## What it reads
|
|
28
|
+
|
|
29
|
+
It reads `CI` from the environment.
|
|
30
|
+
It forwards every argument to `stryker run` through `bun x stryker run`.
|
|
31
|
+
|
|
32
|
+
## Arguments
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
checks-mutation [--mutate <glob>] [--incremental] [<stryker args>...]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every argument after the bin name forwards to `stryker run`.
|
|
39
|
+
|
|
40
|
+
## Exit codes
|
|
41
|
+
|
|
42
|
+
| Code | When |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| 0 | Stryker exited 0 |
|
|
45
|
+
| 1 | Stryker exited nonzero, including an incremental run refused for a missing report |
|
|
46
|
+
| 2 | a full run outside CI was refused, or Stryker could not start |
|
|
47
|
+
|
|
48
|
+
## Sample output
|
|
49
|
+
|
|
50
|
+
A full run outside CI prints its refusal and exits 2:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
checks-mutation: refusing a full mutation run outside CI; start the same run in CI with `gh workflow run mutation`, or scope this run with `--mutate <glob>` or `--incremental`
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A bare `bunx stryker run` fails to load its config with the same refusal as the error and exits 1.
|
|
57
|
+
An `--incremental` run with no report stops before instrumenting with the missing-report refusal as the error and exits 1.
|
|
58
|
+
|
|
59
|
+
## When it runs
|
|
60
|
+
|
|
61
|
+
A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
|
|
62
|
+
Run scoped checks locally during development.
|
|
63
|
+
A scheduled run never starts one, because a baseline costs a full Stryker run.
|
|
64
|
+
|
|
65
|
+
## Running it in CI
|
|
66
|
+
|
|
67
|
+
A repository starts a baseline by hand from a workflow named `mutation` and keeps its report as an artifact:
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
name: mutation
|
|
71
|
+
on:
|
|
72
|
+
workflow_dispatch:
|
|
73
|
+
jobs:
|
|
74
|
+
mutation:
|
|
75
|
+
runs-on: ${{ vars.CI_RUNS_ON || fromJSON('["self-hosted","Linux","X64","winbox"]') }}
|
|
76
|
+
steps:
|
|
77
|
+
- uses: actions/checkout@v5
|
|
78
|
+
- uses: oven-sh/setup-bun@v2
|
|
79
|
+
with:
|
|
80
|
+
bun-version-file: .bun-version
|
|
81
|
+
- run: bun install --frozen-lockfile
|
|
82
|
+
- run: bunx stryker run
|
|
83
|
+
- uses: actions/upload-artifact@v4
|
|
84
|
+
if: always()
|
|
85
|
+
with:
|
|
86
|
+
name: mutation-report
|
|
87
|
+
path: reports/mutation/mutation.json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
With `CI_RUNS_ON` unset, the job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a private repository sends its full sweeps.
|
|
91
|
+
A repository sets `CI_RUNS_ON` only to name a different runner.
|
|
92
|
+
The `name: mutation` line is what `gh workflow run mutation` looks up.
|
|
93
|
+
GitHub sets `CI=true` on every runner, so the preset lets the full run through there.
|
|
94
|
+
|
|
95
|
+
## Related topics
|
|
96
|
+
|
|
97
|
+
- [checks-mutation-compare](checks-mutation-compare.md)
|
|
98
|
+
- [checks-subsumed-tests](checks-subsumed-tests.md)
|
|
@@ -12,6 +12,7 @@ The gate runs jscpd at 50 tokens and 5 lines against the base and head revisions
|
|
|
12
12
|
It compares repeated lines for each file, so a decrease in another file never offsets a rise.
|
|
13
13
|
It follows an edited rename back to the original file.
|
|
14
14
|
A file that repeats lines without a rise is advisory.
|
|
15
|
+
A block two bins need goes into a module both import, as `rangeFromArgs` and `checkoutFiles` in `src/core/git.ts` show.
|
|
15
16
|
|
|
16
17
|
## What it reads
|
|
17
18
|
|
|
@@ -30,6 +30,8 @@ Build a bail-off report with this command:
|
|
|
30
30
|
bunx stryker run --disableBail
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
It is a full sweep, so outside CI the shared preset refuses it unless `--mutate <glob>` scopes it, as [checks-mutation](checks-mutation.md) says.
|
|
34
|
+
|
|
33
35
|
The report records each killer as a test index, so it names each test by its file and its name from the report's `testFiles` table.
|
|
34
36
|
|
|
35
37
|
## Arguments
|
|
@@ -9,6 +9,7 @@ audience: consumers
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
11
11
|
It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
|
|
12
|
+
It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
|
|
12
13
|
Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
|
|
13
14
|
A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
|
|
14
15
|
|
|
@@ -33,6 +34,8 @@ A skip is declared on the test itself and only there.
|
|
|
33
34
|
Add `"ci"` or `"local"` as the third `skipReason` argument when a declaration applies to one environment.
|
|
34
35
|
Omit the third argument when it applies in both environments.
|
|
35
36
|
A declaration for the other environment is not judged in the current run.
|
|
37
|
+
Bun sees `CI` set in every `checks-test` run, so a `"ci"` skip whose condition reads `process.env.CI` also skips in a local run and fails there as undeclared.
|
|
38
|
+
Gate a `"ci"` skip on what CI lacks, such as a daemon or a tool, and never on `process.env.CI`.
|
|
36
39
|
|
|
37
40
|
A declaration whose test passes or does not register fails a CI run.
|
|
38
41
|
A local run warns about the same declaration because a condition can depend on the machine.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"src/testing/test-report.ts",
|
|
37
37
|
"src/testing/flake.ts",
|
|
38
38
|
"src/delivery/commit-identity.ts",
|
|
39
|
+
"src/testing/mutation.ts",
|
|
40
|
+
"src/testing/mutation-scope.js",
|
|
41
|
+
"src/testing/mutation-guard-plugin.js",
|
|
39
42
|
"src/testing/mutation-compare.ts",
|
|
40
43
|
"src/testing/subsumed-tests.ts",
|
|
41
44
|
"src/delivery/ci-wiring.ts",
|
|
@@ -57,6 +60,7 @@
|
|
|
57
60
|
"src/dependencies/advisories.ts",
|
|
58
61
|
"src/dependencies/advisory-rules.ts",
|
|
59
62
|
"src/dependencies/osv-scanner.ts",
|
|
63
|
+
"src/docs/doc-agents.ts",
|
|
60
64
|
"src/docs/doc-outline.ts",
|
|
61
65
|
"src/docs/doc-rules.ts",
|
|
62
66
|
"src/docs/doc-references.ts",
|
|
@@ -94,6 +98,7 @@
|
|
|
94
98
|
"checks-test": "src/testing/test.ts",
|
|
95
99
|
"checks-flake": "src/testing/flake.ts",
|
|
96
100
|
"checks-commit-identity": "src/delivery/commit-identity.ts",
|
|
101
|
+
"checks-mutation": "src/testing/mutation.ts",
|
|
97
102
|
"checks-mutation-compare": "src/testing/mutation-compare.ts",
|
|
98
103
|
"checks-subsumed-tests": "src/testing/subsumed-tests.ts",
|
|
99
104
|
"checks-ci-wiring": "src/delivery/ci-wiring.ts",
|
package/src/complexity/knip.ts
CHANGED
|
@@ -27,7 +27,7 @@ const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
|
|
|
27
27
|
const manifest: unknown = yield* fs.readFileString(path.join(root, "package.json")).pipe(
|
|
28
28
|
Effect.flatMap((text) =>
|
|
29
29
|
Effect.try({
|
|
30
|
-
try: () => JSON.parse(text),
|
|
30
|
+
try: (): unknown => JSON.parse(text),
|
|
31
31
|
catch: () => new KnipError({ message: "package.json does not parse as JSON" }),
|
|
32
32
|
})
|
|
33
33
|
),
|
package/src/core/gates.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type KitGate = {
|
|
|
19
19
|
readonly vector: Vector;
|
|
20
20
|
readonly file: string;
|
|
21
21
|
readonly reads: "tree" | "range";
|
|
22
|
+
readonly alsoReads?: string;
|
|
22
23
|
readonly args?: readonly string[];
|
|
23
24
|
readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
|
|
24
25
|
};
|
|
@@ -40,7 +41,7 @@ export const KIT_GATES = [
|
|
|
40
41
|
{ bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
41
42
|
{ bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
42
43
|
{ bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
43
|
-
{ bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
44
|
+
{ bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
|
|
44
45
|
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
45
46
|
{ bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
46
47
|
{ bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { commandNames, type Snapshot } from "./doc-references.ts";
|
|
2
|
+
import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
3
|
+
|
|
4
|
+
export const AGENT_CEILING = 3000;
|
|
5
|
+
|
|
6
|
+
const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
7
|
+
const INDENT = /^[ \t]*/;
|
|
8
|
+
const CODE_INDENT = 4;
|
|
9
|
+
const INLINE_LINK =
|
|
10
|
+
/(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
11
|
+
const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
|
|
12
|
+
|
|
13
|
+
export type AgentFinding = {
|
|
14
|
+
readonly line: number | undefined;
|
|
15
|
+
readonly message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function isAgentFile(repositoryPath: string): boolean {
|
|
19
|
+
return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ceilingFinding(text: string): AgentFinding | undefined {
|
|
23
|
+
if (text.length <= AGENT_CEILING) return undefined;
|
|
24
|
+
return {
|
|
25
|
+
line: undefined,
|
|
26
|
+
message: `is ${text.length} characters, over the 3,000-character ceiling for agent files. Keep what nearly every session needs plus one pointer per part, move each part's notes into the people doc that covers that part, and delete what a check already holds`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function maintainingFinding(text: string): AgentFinding | undefined {
|
|
31
|
+
const heading = scanMarkdown(text).find(({ kind, raw }) => kind === "heading" && MAINTAINING.test(raw));
|
|
32
|
+
if (heading === undefined) return undefined;
|
|
33
|
+
return {
|
|
34
|
+
line: heading.line,
|
|
35
|
+
message: "holds `## Maintaining this file`, which a router leaves out. Delete the section, since checks-docs holds the file's shape",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function indentOf(raw: string): number {
|
|
40
|
+
return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function entries(text: string): readonly MarkdownLine[] {
|
|
44
|
+
let inList = false;
|
|
45
|
+
let afterBlank = true;
|
|
46
|
+
return scanMarkdown(text).filter((line) => {
|
|
47
|
+
const blank = line.raw.trim() === "";
|
|
48
|
+
const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
|
|
49
|
+
const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
|
|
50
|
+
if (listItem) inList = true;
|
|
51
|
+
else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
|
|
52
|
+
afterBlank = blank;
|
|
53
|
+
return listItem;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Tracked = Pick<Snapshot, "files" | "directories">;
|
|
58
|
+
|
|
59
|
+
const STAYS = new Set(["", "."]);
|
|
60
|
+
|
|
61
|
+
function joined(directory: string, segment: string): string {
|
|
62
|
+
return directory === "" ? segment : `${directory}/${segment}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function enter(directory: string, segment: string, tracked: Tracked): string | undefined {
|
|
66
|
+
if (STAYS.has(segment)) return directory;
|
|
67
|
+
if (segment === "..") return directory === "" ? undefined : directory.slice(0, Math.max(directory.lastIndexOf("/"), 0));
|
|
68
|
+
const next = joined(directory, segment);
|
|
69
|
+
return tracked.directories.has(next) ? next : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function resolvesFrom(directory: string, span: string, tracked: Tracked): boolean {
|
|
73
|
+
const segments = span.split("/");
|
|
74
|
+
const last = segments.pop() ?? "";
|
|
75
|
+
const walked = segments.reduce<string | undefined>((at, segment) => (at === undefined ? undefined : enter(at, segment, tracked)), directory);
|
|
76
|
+
if (walked === undefined) return false;
|
|
77
|
+
if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
|
|
78
|
+
const end = enter(walked, last, tracked);
|
|
79
|
+
return end !== undefined && end !== "" && end !== directory;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
|
|
83
|
+
const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
|
|
84
|
+
const fromFile = resolvesFrom(directory, span, tracked);
|
|
85
|
+
return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function namesLink({ raw, prose }: MarkdownLine): boolean {
|
|
89
|
+
return [...raw.matchAll(INLINE_LINK)].some(({ index }) => prose.charAt(index) === "[");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function points(agentFile: string, line: MarkdownLine, tracked: Tracked): boolean {
|
|
93
|
+
return line.code.some((span) => namesTrackedPath(agentFile, span, tracked)) || namesLink(line) || commandNames(line).length > 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function entryFindings(agentFile: string, text: string, tracked: Tracked): readonly AgentFinding[] {
|
|
97
|
+
return entries(text).flatMap((line) => {
|
|
98
|
+
if (points(agentFile, line, tracked)) return [];
|
|
99
|
+
return [
|
|
100
|
+
{
|
|
101
|
+
line: line.line,
|
|
102
|
+
message: "names no tracked path, link or `bun run` command. Name the file, link or command that holds the detail",
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -131,6 +131,10 @@ function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
|
|
|
131
131
|
return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
export function commandNames(line: MarkdownLine): readonly string[] {
|
|
135
|
+
return commandsOn(line).filter((name) => !NOT_A_NAME.test(name));
|
|
136
|
+
}
|
|
137
|
+
|
|
134
138
|
export type Judging = { readonly commands: boolean };
|
|
135
139
|
|
|
136
140
|
export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
|
package/src/docs/doc-rules.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ruleProblem,
|
|
9
9
|
type Heading,
|
|
10
10
|
type Outline,
|
|
11
|
+
type Section,
|
|
11
12
|
type Violation,
|
|
12
13
|
VERSION,
|
|
13
14
|
} from "./doc-outline.ts";
|
|
@@ -93,8 +94,76 @@ function recordNumber(path: string): number | undefined {
|
|
|
93
94
|
return name === undefined ? undefined : Number(name);
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const REVISION_CLAUSE = /\b(?:amend(?:s|ed)|narrow(?:s|ed)|supersede[sd])\b(?:[^.]|\.(?=\S))*/gi;
|
|
98
|
+
const RECORD_REFERENCE = /\b(\d{4})\b/g;
|
|
99
|
+
|
|
100
|
+
function numbersIn(text: string): ReadonlySet<number> {
|
|
101
|
+
return new Set(Array.from(text.matchAll(RECORD_REFERENCE), (match) => Number(match[1])));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function revisedIn(status: string): ReadonlySet<number> {
|
|
105
|
+
return new Set((status.match(REVISION_CLAUSE) ?? []).flatMap((clause) => [...numbersIn(clause)]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function padded(number: number): string {
|
|
109
|
+
return String(number).padStart(4, "0");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function outlineOf(text: string): Outline {
|
|
113
|
+
return parseOutline(text.slice(frontMatterOf(text).text.length));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function statusSection(outline: Outline): Section | undefined {
|
|
117
|
+
return outline.sections.find(({ heading }) => heading.title === "Status");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type StatusLinks = {
|
|
121
|
+
readonly cited: ReadonlySet<number>;
|
|
122
|
+
readonly revised: ReadonlySet<number>;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function statusLinks(section: Section): StatusLinks {
|
|
126
|
+
const text = section.body.map(({ text: body }) => body).join("\n");
|
|
127
|
+
return { cited: numbersIn(text), revised: revisedIn(text) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type Records = {
|
|
131
|
+
readonly paths: readonly string[];
|
|
132
|
+
readonly links: ReadonlyMap<number, StatusLinks>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function recordsOf(records: readonly Doc[]): Records {
|
|
136
|
+
const links = new Map<number, StatusLinks>();
|
|
137
|
+
for (const { path, text } of records) {
|
|
138
|
+
const number = recordNumber(path);
|
|
139
|
+
const section = number === undefined || links.has(number) ? undefined : statusSection(outlineOf(text));
|
|
140
|
+
if (number !== undefined && section !== undefined) links.set(number, statusLinks(section));
|
|
141
|
+
}
|
|
142
|
+
return { paths: records.map(({ path }) => path), links };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function pairProblems(filed: number, own: Section, mine: StatusLinks, number: number, other: StatusLinks): readonly Violation[] {
|
|
146
|
+
if (number === filed) return [];
|
|
147
|
+
if (mine.revised.has(number) && !other.cited.has(filed)) {
|
|
148
|
+
const line = own.body.find(({ text: body }) => body.includes(padded(number)))?.line ?? own.heading.line;
|
|
149
|
+
return [{ line, message: `\`## Status\` names ${padded(number)} without ${padded(number)} naming ${padded(filed)} back` }];
|
|
150
|
+
}
|
|
151
|
+
if (other.revised.has(filed) && !mine.cited.has(number)) {
|
|
152
|
+
return [{ line: own.heading.line, message: `\`## Status\` is named by ${padded(number)} without naming ${padded(number)} back` }];
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function revisionLinkProblems(path: string, outline: Outline, links: ReadonlyMap<number, StatusLinks>): readonly Violation[] {
|
|
158
|
+
const filed = recordNumber(path);
|
|
159
|
+
const own = filed === undefined ? undefined : statusSection(outline);
|
|
160
|
+
if (filed === undefined || own === undefined) return [];
|
|
161
|
+
const mine = statusLinks(own);
|
|
162
|
+
return [...links].flatMap(([number, other]) => pairProblems(filed, own, mine, number, other));
|
|
163
|
+
}
|
|
164
|
+
|
|
96
165
|
function statusProblem(outline: Outline): Violation | undefined {
|
|
97
|
-
const status = outline
|
|
166
|
+
const status = statusSection(outline);
|
|
98
167
|
if (status === undefined) return undefined;
|
|
99
168
|
const opening = firstText(status.body);
|
|
100
169
|
const word = opening?.text.trim().split(/\s+/, 1)[0]?.replace(/[.,;:]+$/, "");
|
|
@@ -126,7 +195,7 @@ function sharedNumberProblem(path: string, filed: number | undefined, records: r
|
|
|
126
195
|
return sharing.length === 0 ? undefined : { line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` };
|
|
127
196
|
}
|
|
128
197
|
|
|
129
|
-
function adrProblems(path: string, outline: Outline, records:
|
|
198
|
+
function adrProblems(path: string, outline: Outline, records: Records): readonly Violation[] {
|
|
130
199
|
const filed = recordNumber(path);
|
|
131
200
|
const misnamed: Violation | undefined =
|
|
132
201
|
filed === undefined
|
|
@@ -137,7 +206,8 @@ function adrProblems(path: string, outline: Outline, records: readonly string[])
|
|
|
137
206
|
recordTitleProblem(outline, filed),
|
|
138
207
|
recordDateProblem(outline),
|
|
139
208
|
statusProblem(outline),
|
|
140
|
-
sharedNumberProblem(path, filed, records),
|
|
209
|
+
sharedNumberProblem(path, filed, records.paths),
|
|
210
|
+
...revisionLinkProblems(path, outline, records.links),
|
|
141
211
|
].filter((violation) => violation !== undefined);
|
|
142
212
|
}
|
|
143
213
|
|
|
@@ -167,7 +237,7 @@ function stepsProblems(kind: Kind, { prose }: Outline): readonly Violation[] {
|
|
|
167
237
|
return [{ line: 1, message: `numbers no steps, which a ${kind} page lists as \`1.\` items` }];
|
|
168
238
|
}
|
|
169
239
|
|
|
170
|
-
function kindProblems(kind: Kind, doc: Doc, outline: Outline, records:
|
|
240
|
+
function kindProblems(kind: Kind, doc: Doc, outline: Outline, records: Records): readonly Violation[] {
|
|
171
241
|
if (kind === "adr") return adrProblems(doc.path, outline, records);
|
|
172
242
|
if (kind === "changelog") return changelogProblems(outline);
|
|
173
243
|
if (kind === "how-to" || kind === "tutorial") return stepsProblems(kind, outline);
|
|
@@ -182,7 +252,7 @@ function exactProblems(kind: Kind, expected: string, actual: string): readonly V
|
|
|
182
252
|
return [{ line, message: `differs from ${templateFile(kind)}, which it holds word for word` }];
|
|
183
253
|
}
|
|
184
254
|
|
|
185
|
-
export function judge(kind: Kind, doc: Doc, records:
|
|
255
|
+
export function judge(kind: Kind, doc: Doc, records: Records): readonly Violation[] {
|
|
186
256
|
const template = TEMPLATES[kind];
|
|
187
257
|
if (template.shape === "exact") return exactProblems(kind, template.text, doc.text);
|
|
188
258
|
const frontMatter = frontMatterOf(doc.text).text;
|
|
@@ -57,14 +57,6 @@ const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequis
|
|
|
57
57
|
|
|
58
58
|
const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
|
|
59
59
|
|
|
60
|
-
const MAINTAINING = [
|
|
61
|
-
"Keep this file for knowledge useful to almost every future agent session in this project.",
|
|
62
|
-
"Do not repeat what the codebase already shows.",
|
|
63
|
-
"Point to the authoritative file or command instead.",
|
|
64
|
-
"Prefer rewriting or pruning existing entries over appending new ones.",
|
|
65
|
-
"When updating this file, preserve this bar for all agents and keep entries concise.",
|
|
66
|
-
];
|
|
67
|
-
|
|
68
60
|
export const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"] as const;
|
|
69
61
|
export type ChangeGroup = (typeof CHANGE_GROUPS)[number];
|
|
70
62
|
|
|
@@ -122,7 +114,6 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
|
122
114
|
open("<A topic an agent needs>", "any", optional("the lead holds every constraint"), [
|
|
123
115
|
"- <A constraint an agent cannot infer from the code, and the file that holds its detail.>",
|
|
124
116
|
]),
|
|
125
|
-
fixed("Maintaining this file", REQUIRED, MAINTAINING),
|
|
126
117
|
],
|
|
127
118
|
},
|
|
128
119
|
claude: {
|
package/src/docs/docs.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
|
|
4
|
+
import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
5
|
+
import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
|
|
5
6
|
import { vanishedNames } from "./doc-names.ts";
|
|
6
7
|
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
7
8
|
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
@@ -18,6 +19,7 @@ type Judged = {
|
|
|
18
19
|
readonly held: readonly string[];
|
|
19
20
|
readonly edited: { readonly docs: number; readonly lines: number };
|
|
20
21
|
readonly named: number;
|
|
22
|
+
readonly agents: number;
|
|
21
23
|
readonly findings: readonly Finding[];
|
|
22
24
|
readonly advisory: ReadonlyMap<string, number>;
|
|
23
25
|
readonly brokenBefore: readonly Finding[];
|
|
@@ -38,7 +40,7 @@ const NAME = "docs";
|
|
|
38
40
|
const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
|
|
39
41
|
const MARKDOWN = [":(glob)**/*.md"];
|
|
40
42
|
|
|
41
|
-
function templateFindings(path: string, text: string, placement: Placement, records:
|
|
43
|
+
function templateFindings(path: string, text: string, placement: Placement, records: Records): readonly Finding[] {
|
|
42
44
|
const misplaced = placementProblem(placement);
|
|
43
45
|
if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
|
|
44
46
|
if (placement.type !== "judged") return [];
|
|
@@ -89,13 +91,13 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
89
91
|
const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
|
|
90
92
|
const changed = yield* changedLines(base, head, MARKDOWN, root);
|
|
91
93
|
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
92
|
-
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
93
94
|
const proseDocs = present.flatMap((path) => {
|
|
94
95
|
const reader = readerOf(path);
|
|
95
96
|
return reader === undefined ? [] : [{ path, reader }];
|
|
96
97
|
});
|
|
97
98
|
const texts = yield* readTexts(root, head, [...new Set([...present.filter((path) => path.endsWith(".md")), ...proseDocs.map(({ path }) => path)])]);
|
|
98
99
|
const text = (path: string): string => texts.get(path) ?? "";
|
|
100
|
+
const records = recordsOf(present.filter((path) => path.startsWith(ADR_DIRECTORY)).map((path) => ({ path, text: text(path) })));
|
|
99
101
|
const judged = present.map((path) => ({ path, placement: placementOf(path, text(path)) })).filter(({ placement }) => placement.type !== "unjudged");
|
|
100
102
|
|
|
101
103
|
const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
|
|
@@ -109,13 +111,27 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
109
111
|
const referenced = new Map(proseDocs.map(({ path }) => [path, text(path)]));
|
|
110
112
|
const references = yield* referenceFindings({ root, base, head, roots, changed, renamedFrom }, referenced, judging);
|
|
111
113
|
const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
|
|
114
|
+
const agents = proseDocs.filter(({ path }) => isAgentFile(path));
|
|
115
|
+
const tracked = snapshotOf(yield* pathsAt(head, [], root), new Map(), new Map());
|
|
116
|
+
const shapes = agents.flatMap(({ path }) =>
|
|
117
|
+
[ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
|
|
118
|
+
);
|
|
119
|
+
const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
|
|
112
120
|
const advisory = new Map<string, number>();
|
|
113
121
|
for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
114
122
|
return {
|
|
115
123
|
held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
|
|
116
124
|
edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
|
|
117
125
|
named: referenced.size,
|
|
118
|
-
|
|
126
|
+
agents: agents.length,
|
|
127
|
+
findings: [
|
|
128
|
+
...templated.filter((finding) => touched.has(finding.path)),
|
|
129
|
+
...prose,
|
|
130
|
+
...references.failing,
|
|
131
|
+
...vanished,
|
|
132
|
+
...shapes,
|
|
133
|
+
...entries,
|
|
134
|
+
].toSorted(inPathOrder),
|
|
119
135
|
advisory,
|
|
120
136
|
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
121
137
|
} satisfies Judged;
|
|
@@ -125,13 +141,14 @@ function describe({ path, line, message }: Finding): string {
|
|
|
125
141
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
export function report({ held, edited, named, findings, advisory, brokenBefore }: Judged): string {
|
|
144
|
+
export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
|
|
129
145
|
const verdict =
|
|
130
146
|
findings.length === 0
|
|
131
147
|
? [
|
|
132
148
|
`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
|
|
133
149
|
`${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
|
|
134
150
|
`${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
|
|
151
|
+
`${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
|
|
135
152
|
]
|
|
136
153
|
: [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
|
|
137
154
|
const unconformed =
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { missingReportRefusal } from "./mutation-scope.js";
|
|
2
|
+
|
|
3
|
+
export const GUARD_IGNORER = "checks-incremental-report-guard";
|
|
4
|
+
|
|
5
|
+
// An ignorer is built before instrumentation from the resolved options, so a throw here stops the run before any mutant.
|
|
6
|
+
function incrementalReportGuard(options) {
|
|
7
|
+
const refusal = missingReportRefusal(process.argv.slice(3), process.env.CI ?? "", options.incrementalFile);
|
|
8
|
+
if (refusal !== undefined) throw new Error(refusal);
|
|
9
|
+
return { shouldIgnore: () => undefined };
|
|
10
|
+
}
|
|
11
|
+
incrementalReportGuard.inject = ["options"];
|
|
12
|
+
|
|
13
|
+
export const strykerPlugins = [{ kind: "Ignore", name: GUARD_IGNORER, factory: incrementalReportGuard }];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const NON_RUNS = ["--help", "-h", "--version"];
|
|
4
|
+
const MUTATE_FLAGS = ["--mutate", "-m"];
|
|
5
|
+
|
|
6
|
+
function namesGlob(args) {
|
|
7
|
+
return args.some(
|
|
8
|
+
(arg, index) =>
|
|
9
|
+
(MUTATE_FLAGS.includes(arg) && (args[index + 1] ?? "") !== "") ||
|
|
10
|
+
(arg.startsWith("--mutate=") && arg !== "--mutate="),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function allowedAnyway(args, ci) {
|
|
15
|
+
return ci === "true" || args.some((arg) => NON_RUNS.includes(arg)) || namesGlob(args);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fullRunRefusal(args, ci) {
|
|
19
|
+
if (allowedAnyway(args, ci) || (args.includes("--incremental") && !args.includes("--force"))) return undefined;
|
|
20
|
+
return "refusing a full mutation run outside CI; start the same run in CI with `gh workflow run mutation`, or scope this run with `--mutate <glob>` or `--incremental`";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Only the resolved options know the incrementalFile a consumer config sets, so this runs after config load.
|
|
24
|
+
export function missingReportRefusal(args, ci, incrementalFile, reportExists = existsSync) {
|
|
25
|
+
if (allowedAnyway(args, ci) || reportExists(incrementalFile)) return undefined;
|
|
26
|
+
return `refusing a full mutation run outside CI: \`--incremental\` finds no report at ${incrementalFile} to reuse; scope this run with \`--mutate <glob>\`, or start the full baseline in CI with \`gh workflow run mutation\``;
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Config, Effect, Schema } from "effect";
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
+
import { runMain } from "../core/main.ts";
|
|
5
|
+
import { fullRunRefusal } from "./mutation-scope.js";
|
|
6
|
+
|
|
7
|
+
export class MutationError extends Schema.TaggedError<MutationError>()("MutationError", {
|
|
8
|
+
message: Schema.String,
|
|
9
|
+
}) {}
|
|
10
|
+
|
|
11
|
+
const readCi = Config.String("CI").pipe(
|
|
12
|
+
Config.withDefault(""),
|
|
13
|
+
Effect.mapError((cause) => new MutationError({ message: `cannot read CI: ${cause.message}` })),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const runStryker = Effect.fn("runStryker")(function* (args: readonly string[]) {
|
|
17
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
18
|
+
const exitCode = yield* spawner.exitCode(
|
|
19
|
+
ChildProcess.make(process.execPath, ["x", "stryker", "run", ...args], {
|
|
20
|
+
stdin: "ignore",
|
|
21
|
+
stdout: "inherit",
|
|
22
|
+
stderr: "inherit",
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
return exitCode === ChildProcessSpawner.ExitCode(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const mutation = Effect.gen(function* () {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const refusal = fullRunRefusal(args, yield* readCi);
|
|
31
|
+
if (refusal !== undefined) return yield* new MutationError({ message: refusal });
|
|
32
|
+
return yield* runStryker(args);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (import.meta.main) runMain("checks-mutation", mutation);
|
package/src/testing/test.ts
CHANGED
|
@@ -118,7 +118,13 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
|
|
|
118
118
|
const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
|
|
119
119
|
const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
|
|
120
120
|
return yield* spawner.exitCode(
|
|
121
|
-
ChildProcess.make(process.execPath, args, {
|
|
121
|
+
ChildProcess.make(process.execPath, args, {
|
|
122
|
+
stdin: "ignore",
|
|
123
|
+
stdout: "inherit",
|
|
124
|
+
stderr: "inherit",
|
|
125
|
+
env: { CI: "true" },
|
|
126
|
+
extendEnv: true,
|
|
127
|
+
}),
|
|
122
128
|
);
|
|
123
129
|
});
|
|
124
130
|
|
package/stryker.preset.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { GUARD_IGNORER } from "./src/testing/mutation-guard-plugin.js";
|
|
3
|
+
import { fullRunRefusal } from "./src/testing/mutation-scope.js";
|
|
4
|
+
|
|
5
|
+
const [, , command, ...args] = process.argv;
|
|
6
|
+
const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
|
|
7
|
+
if (refusal !== undefined) throw new Error(refusal);
|
|
8
|
+
|
|
1
9
|
export default {
|
|
2
10
|
packageManager: "npm",
|
|
3
|
-
plugins: [
|
|
11
|
+
plugins: [
|
|
12
|
+
"@stryker-mutator/*",
|
|
13
|
+
"@hughescr/stryker-bun-runner",
|
|
14
|
+
fileURLToPath(new URL("./src/testing/mutation-guard-plugin.js", import.meta.url)),
|
|
15
|
+
],
|
|
16
|
+
ignorers: [GUARD_IGNORER],
|
|
4
17
|
testRunner: "bun",
|
|
5
18
|
bun: { timeout: 60000 },
|
|
6
19
|
inPlace: true,
|