@avi2dg/checks 0.29.0 → 0.31.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 +17 -0
- package/README.md +4 -3
- package/dist/readability/index.js +94 -1
- package/docs/configs/typescript-rules.md +10 -0
- package/docs/design.md +2 -2
- package/docs/gates/checks-docs.md +1 -0
- package/docs/gates/checks-lint-coverage.md +9 -8
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +24 -5
- package/docs/gates/checks-mutation.md +98 -0
- package/docs/gates/checks-subsumed-tests.md +2 -0
- package/docs/gates/checks-unused.md +7 -6
- package/oxlintrc.json +7 -0
- package/package.json +14 -2
- package/src/complexity/exports.ts +2 -1
- package/src/complexity/knip.ts +4 -5
- package/src/complexity/unused.ts +4 -3
- package/src/core/gates.ts +6 -4
- package/src/delivery/commit-identity.ts +1 -1
- package/src/docs/doc-rules.ts +75 -5
- package/src/docs/docs.ts +3 -3
- package/src/quality/lint-coverage.sh +11 -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/stryker.preset.js +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.31.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-28.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- lint .astro files in lint coverage, unused and a thin-frontmatter rule [#108](https://github.com/avi2d/checks/pull/108)
|
|
12
|
+
|
|
13
|
+
## 0.30.0
|
|
14
|
+
|
|
15
|
+
Released 2026-09-28.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **testing:** refuse full mutation runs outside CI [#106](https://github.com/avi2d/checks/pull/106)
|
|
20
|
+
- **docs:** refuse a decision-record revision link named on one side only [#105](https://github.com/avi2d/checks/pull/105)
|
|
21
|
+
|
|
5
22
|
## 0.29.0
|
|
6
23
|
|
|
7
24
|
Released 2026-09-28.
|
package/README.md
CHANGED
|
@@ -118,9 +118,9 @@ The table groups the gates by vector, the part of a repository each one judges.
|
|
|
118
118
|
| --- | --- | --- | --- |
|
|
119
119
|
| complexity | [`checks-suppressions-ratchet`](docs/gates/checks-suppressions-ratchet.md) | the range | every repository |
|
|
120
120
|
| complexity | [`checks-repetition`](docs/gates/checks-repetition.md) | the range | a repository tracking `*.ts` or `*.tsx` |
|
|
121
|
-
| complexity | [`checks-unused`](docs/gates/checks-unused.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
|
|
121
|
+
| complexity | [`checks-unused`](docs/gates/checks-unused.md) | the working tree | a repository tracking `*.ts` or `*.tsx` or `*.astro` |
|
|
122
122
|
| complexity | [`checks-exports`](docs/gates/checks-exports.md) | the range | a repository tracking `*.ts` or `*.tsx` |
|
|
123
|
-
| quality | [`checks-lint-coverage`](docs/gates/checks-lint-coverage.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
|
|
123
|
+
| quality | [`checks-lint-coverage`](docs/gates/checks-lint-coverage.md) | the working tree | a repository tracking `*.ts` or `*.tsx` or `*.astro` |
|
|
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 |
|
|
@@ -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
|
|
|
@@ -416,11 +416,104 @@ var rule = {
|
|
|
416
416
|
};
|
|
417
417
|
var cognitive_complexity_default = rule;
|
|
418
418
|
|
|
419
|
+
// src/complexity/readability/thin-astro.ts
|
|
420
|
+
function unwrapped(expression) {
|
|
421
|
+
let current = expression;
|
|
422
|
+
while (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression" || current.type === "TSTypeAssertion") {
|
|
423
|
+
current = current.expression;
|
|
424
|
+
}
|
|
425
|
+
return current;
|
|
426
|
+
}
|
|
427
|
+
function isAstroProps(value) {
|
|
428
|
+
if (value.type !== "MemberExpression" || value.computed)
|
|
429
|
+
return false;
|
|
430
|
+
const { object, property } = value;
|
|
431
|
+
return object.type === "Identifier" && object.name === "Astro" && property.type === "Identifier" && property.name === "props";
|
|
432
|
+
}
|
|
433
|
+
function isPlainValue(value, bound) {
|
|
434
|
+
return value.type === "Literal" || value.type === "Identifier" && bound.has(value.name);
|
|
435
|
+
}
|
|
436
|
+
function readsProps(expression, bound) {
|
|
437
|
+
const value = unwrapped(expression);
|
|
438
|
+
if (value.type !== "MemberExpression")
|
|
439
|
+
return value.type === "Identifier" && bound.has(value.name);
|
|
440
|
+
if (value.computed && !isPlainValue(value.property, bound))
|
|
441
|
+
return false;
|
|
442
|
+
return isAstroProps(value) || readsProps(value.object, bound);
|
|
443
|
+
}
|
|
444
|
+
function isPlainPattern(pattern, bound) {
|
|
445
|
+
if (pattern === null)
|
|
446
|
+
return true;
|
|
447
|
+
if (pattern.type === "RestElement")
|
|
448
|
+
return isPlainPattern(pattern.argument, bound);
|
|
449
|
+
if (pattern.type === "AssignmentPattern")
|
|
450
|
+
return isPlainValue(pattern.right, bound) && isPlainPattern(pattern.left, bound);
|
|
451
|
+
if (pattern.type === "ArrayPattern")
|
|
452
|
+
return pattern.elements.every((element) => isPlainPattern(element, bound));
|
|
453
|
+
if (pattern.type === "Identifier")
|
|
454
|
+
return true;
|
|
455
|
+
return pattern.properties.every((property) => property.type === "RestElement" ? isPlainPattern(property.argument, bound) : (!property.computed || isPlainValue(property.key, bound)) && isPlainPattern(property.value, bound));
|
|
456
|
+
}
|
|
457
|
+
function isPropsRead(declarator, bound) {
|
|
458
|
+
return declarator.init !== null && readsProps(declarator.init, bound) && isPlainPattern(declarator.id, bound);
|
|
459
|
+
}
|
|
460
|
+
function boundName(pattern, found) {
|
|
461
|
+
if (pattern === null)
|
|
462
|
+
return;
|
|
463
|
+
if (pattern.type === "RestElement")
|
|
464
|
+
boundName(pattern.argument, found);
|
|
465
|
+
else if (pattern.type === "AssignmentPattern")
|
|
466
|
+
boundName(pattern.left, found);
|
|
467
|
+
else if (pattern.type === "ObjectPattern") {
|
|
468
|
+
for (const property of pattern.properties)
|
|
469
|
+
boundName(property.type === "Property" ? property.value : property.argument, found);
|
|
470
|
+
} else if (pattern.type === "ArrayPattern") {
|
|
471
|
+
for (const element of pattern.elements)
|
|
472
|
+
boundName(element, found);
|
|
473
|
+
} else
|
|
474
|
+
found.add(pattern.name);
|
|
475
|
+
}
|
|
476
|
+
function isTypeDeclaration(statement) {
|
|
477
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
478
|
+
return declaration?.type === "TSInterfaceDeclaration" || declaration?.type === "TSTypeAliasDeclaration";
|
|
479
|
+
}
|
|
480
|
+
var rule2 = {
|
|
481
|
+
meta: {
|
|
482
|
+
type: "problem",
|
|
483
|
+
docs: { description: 'Disallow logic in .astro frontmatter and script blocks: only imports, props and markup, with client code loaded by a side-effect import such as import "../client.ts"' }
|
|
484
|
+
},
|
|
485
|
+
create(context) {
|
|
486
|
+
if (!context.filename.endsWith(".astro"))
|
|
487
|
+
return {};
|
|
488
|
+
const bound = new Set;
|
|
489
|
+
return {
|
|
490
|
+
Program(node) {
|
|
491
|
+
for (const statement of node.body) {
|
|
492
|
+
if (statement.type === "ImportDeclaration" || statement.type === "EmptyStatement" || statement.type === "ExportNamedDeclaration" && statement.source !== null || isTypeDeclaration(statement)) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (statement.type === "VariableDeclaration" && statement.declarations.length > 0 && statement.declarations.every((declarator) => isPropsRead(declarator, bound))) {
|
|
496
|
+
for (const declarator of statement.declarations)
|
|
497
|
+
boundName(declarator.id, bound);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
context.report({
|
|
501
|
+
node: statement,
|
|
502
|
+
message: 'an .astro frontmatter or script block holds more than imports and props: move this statement into a .ts file and import it, so the .astro file holds only imports, props and markup; a script block loads client code with a side-effect import such as import "../client.ts"'
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
var thin_astro_default = rule2;
|
|
510
|
+
|
|
419
511
|
// src/complexity/readability/index.ts
|
|
420
512
|
var plugin = {
|
|
421
513
|
meta: { name: "readability" },
|
|
422
514
|
rules: {
|
|
423
|
-
"cognitive-complexity": cognitive_complexity_default
|
|
515
|
+
"cognitive-complexity": cognitive_complexity_default,
|
|
516
|
+
"thin-astro": thin_astro_default
|
|
424
517
|
}
|
|
425
518
|
};
|
|
426
519
|
var readability_default = plugin;
|
|
@@ -43,6 +43,16 @@ The base loads the kit's `data-shape` plugin from `dist/` with one rule for ever
|
|
|
43
43
|
- Derive such a type from the schema with `typeof Name.Type` instead of writing both.
|
|
44
44
|
- The twin rule runs on production files only, so a test that declares its own schema as an oracle stays green.
|
|
45
45
|
|
|
46
|
+
## Astro rules
|
|
47
|
+
|
|
48
|
+
An override in `oxlintrc.json` turns on one rule of the kit's `readability` plugin in each `.astro` file:
|
|
49
|
+
|
|
50
|
+
- `readability/thin-astro` refuses a statement in the frontmatter or a script block that is neither an import, a re-export from another module, a type or interface declaration, nor a variable read from `Astro.props`.
|
|
51
|
+
- A default inside an `Astro.props` destructuring passes only when it is a literal or a name read from `Astro.props` earlier, so `const { title = "Home" } = Astro.props;` passes and a call or `await` in a default is refused.
|
|
52
|
+
- Move a refused statement into a `.ts` file and import it, so the `.astro` file holds only imports, props and markup.
|
|
53
|
+
- A script block loads client code with a side-effect import, as in `<script>import "../client.ts";</script>`, and the override turns off `import/no-unassigned-import` so that import passes.
|
|
54
|
+
- A dynamic route re-exports `getStaticPaths` from a `.ts` file, as in `export { getStaticPaths } from "../lib/paths.ts";`.
|
|
55
|
+
|
|
46
56
|
## Rules outside tests
|
|
47
57
|
|
|
48
58
|
An override in `oxlintrc.json` turns on these type-aware rules in each `.ts` and `.tsx` file outside `tests/`:
|
package/docs/design.md
CHANGED
|
@@ -86,7 +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
|
+
`@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`, except `readability/thin-astro`, which `tests/e2e/astro-consumer.test.ts` proves against a consumer tree linked to the checkout.
|
|
90
90
|
`dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
|
|
91
91
|
No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
|
|
92
92
|
CI runs `git diff --exit-code` over the whole tree after `bun run build`.
|
|
@@ -109,7 +109,7 @@ So the three packages move together at one exact version.
|
|
|
109
109
|
A gate then behaves the same alone or through `checks-lint`, and `lint-coverage.sh` can stay a shell script.
|
|
110
110
|
The gates run one at a time and pass their output straight through, so each report reads whole and in the order of the gate table.
|
|
111
111
|
`checks-lint` picks the gates that apply from the tracked files, and a repository cannot select gates.
|
|
112
|
-
A TypeScript gate runs as soon as the repository tracks TypeScript source.
|
|
112
|
+
A TypeScript gate runs as soon as the repository tracks TypeScript source, and `checks-lint-coverage` and `checks-unused` also run on tracked Astro source.
|
|
113
113
|
|
|
114
114
|
GitHub authors the pull request merge commit it builds as `GitHub <noreply@github.com>`, and `checks-commit-identity` refuses that author.
|
|
115
115
|
So `checks-lint` ends a pull request's range at the event's head commit, and the merge commit is never in it.
|
|
@@ -61,6 +61,7 @@ A template decides a file's structure, and the template file itself is the refer
|
|
|
61
61
|
A `Date: YYYY-MM-DD` line follows the title.
|
|
62
62
|
The first word under Status is Proposed, Accepted, Rejected, Deprecated, Superseded or Retired.
|
|
63
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.
|
|
64
65
|
- A changelog lists its releases newest first, and each opens with a `Released YYYY-MM-DD.` line.
|
|
65
66
|
- A how-to or tutorial page numbers its steps.
|
|
66
67
|
- `CLAUDE.md` is its template word for word.
|
|
@@ -4,12 +4,13 @@ audience: consumers
|
|
|
4
4
|
---
|
|
5
5
|
# checks-lint-coverage
|
|
6
6
|
|
|
7
|
-
`checks-lint-coverage` is the gate that fails when oxlint skips a tracked TypeScript file, or when the program `tsconfig.json` builds drops the ts-reset rules, without saying so.
|
|
7
|
+
`checks-lint-coverage` is the gate that fails when oxlint skips a tracked TypeScript or Astro file, or when the program `tsconfig.json` builds drops the ts-reset rules, without saying so.
|
|
8
8
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
11
|
-
It fails when oxlint skips a tracked `.ts` or `.
|
|
11
|
+
It fails when oxlint skips a tracked `.ts`, `.tsx` or `.astro` file, for example through a stray `.gitignore` entry.
|
|
12
12
|
It compares `git ls-files` against oxlint's own file walk and names the missing files.
|
|
13
|
+
It lists each tracked `.astro` file the same way, since oxlint lints the frontmatter and script blocks of an `.astro` file, where a script block loads client code with a side-effect import such as `import "../client.ts";`.
|
|
13
14
|
|
|
14
15
|
It fails when the program `tsconfig.json` builds leaves out the `is-array` or the `json-parse` rule of `@total-typescript/ts-reset`, which `tsconfig.effect.json` lists.
|
|
15
16
|
A `tsconfig.json` that does not extend `@avi2dg/checks/tsconfig.effect.json`, or that sets both `files` and `include`, leaves both rules out.
|
|
@@ -17,9 +18,9 @@ A `tsconfig.json` that does not extend `@avi2dg/checks/tsconfig.effect.json`, or
|
|
|
17
18
|
|
|
18
19
|
## What it reads
|
|
19
20
|
|
|
20
|
-
It reads the working tree: the `*.ts` and `*.
|
|
21
|
+
It reads the working tree: the `*.ts`, `*.tsx` and `*.astro` files `git ls-files` lists, and the files `oxlint --debug=files` walks.
|
|
21
22
|
It walks without naming a path, since an explicit path bypasses the ignore files whose skips it looks for.
|
|
22
|
-
It reads the program from `tsc --listFilesOnly -p tsconfig.json`, and passes over the program when the root holds no `tsconfig.json`.
|
|
23
|
+
It reads the program from `tsc --listFilesOnly -p tsconfig.json`, and passes over the program when the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json`.
|
|
23
24
|
oxlint and tsc must be on `PATH`, as they are under a package script.
|
|
24
25
|
|
|
25
26
|
## Arguments
|
|
@@ -30,14 +31,14 @@ It takes none.
|
|
|
30
31
|
|
|
31
32
|
| Code | When |
|
|
32
33
|
| --- | --- |
|
|
33
|
-
| 0 | the repository tracks no `.ts` or `.
|
|
34
|
+
| 0 | the repository tracks no `.ts`, `.tsx` or `.astro` file, or oxlint walks each one and the program holds both ts-reset rules, the repository tracks no `.ts` or `.tsx` file or the root holds no `tsconfig.json` |
|
|
34
35
|
| 1 | oxlint skips a tracked file, or the program drops a ts-reset rule |
|
|
35
36
|
| 2 | oxlint cannot walk the tree, or tsc cannot list the program after oxlint walks every tracked file, as when either is not on `PATH` or a config does not parse |
|
|
36
37
|
|
|
37
38
|
## Sample output
|
|
38
39
|
|
|
39
40
|
```
|
|
40
|
-
lint-coverage: oxlint skips 1/3 tracked .ts/.tsx files; missing:
|
|
41
|
+
lint-coverage: oxlint skips 1/3 tracked .ts/.tsx/.astro files; missing:
|
|
41
42
|
ignored/b.ts
|
|
42
43
|
lint-coverage: the program tsconfig.json builds drops the ts-reset rules: is-array json-parse
|
|
43
44
|
extend @avi2dg/checks/tsconfig.effect.json, and set files or include in tsconfig.json but not both
|
|
@@ -46,13 +47,13 @@ lint-coverage: the program tsconfig.json builds drops the ts-reset rules: is-arr
|
|
|
46
47
|
A passing run counts the files and names the rules:
|
|
47
48
|
|
|
48
49
|
```
|
|
49
|
-
lint-coverage: 71/71 tracked .ts/.tsx files
|
|
50
|
+
lint-coverage: 71/71 tracked .ts/.tsx/.astro files
|
|
50
51
|
lint-coverage: the program tsconfig.json builds holds the ts-reset rules is-array and json-parse
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
## When it runs
|
|
54
55
|
|
|
55
|
-
`checks-lint` runs it when the repository tracks a `.ts` or `.
|
|
56
|
+
`checks-lint` runs it when the repository tracks a `.ts`, `.tsx` or `.astro` file.
|
|
56
57
|
|
|
57
58
|
## Related topics
|
|
58
59
|
|
|
@@ -10,6 +10,7 @@ audience: consumers
|
|
|
10
10
|
|
|
11
11
|
It runs the gates under [What runs](../../README.md#what-runs), each in its own process.
|
|
12
12
|
Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
|
|
13
|
+
`checks-lint-coverage` and `checks-unused` also begin running when the repository tracks an `.astro` file.
|
|
13
14
|
`checks-advisories` begins running when the repository tracks `bun.lock`.
|
|
14
15
|
All other gates run for every repository.
|
|
15
16
|
|
|
@@ -53,7 +54,7 @@ checks-lint: 1 of 12 gate(s) failed: checks-comment-gate
|
|
|
53
54
|
## When it runs
|
|
54
55
|
|
|
55
56
|
A repository runs it from `bun run lint` in a pull request workflow that fetches the whole git history.
|
|
56
|
-
It leaves out the TypeScript gates while the repository tracks no TypeScript file.
|
|
57
|
+
It leaves out the TypeScript gates while the repository tracks no TypeScript file, except that `checks-lint-coverage` and `checks-unused` run when it tracks an `.astro` file.
|
|
57
58
|
|
|
58
59
|
## Related topics
|
|
59
60
|
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -4,14 +4,14 @@ audience: consumers
|
|
|
4
4
|
---
|
|
5
5
|
# checks-unused
|
|
6
6
|
|
|
7
|
-
`checks-unused` is the gate that refuses a TypeScript file no entry point reaches.
|
|
7
|
+
`checks-unused` is the gate that refuses a TypeScript or Astro file no entry point reaches.
|
|
8
8
|
|
|
9
9
|
## What it checks
|
|
10
10
|
|
|
11
|
-
It runs Knip with the repository's own configuration and names each `.ts` or `.
|
|
11
|
+
It runs Knip with the repository's own configuration and names each `.ts`, `.tsx` or `.astro` file no entry reaches.
|
|
12
12
|
It reads only the files issue type, so an unused export or dependency never fails it.
|
|
13
13
|
It asks Knip for that issue type itself, so a configuration that narrows `include`, excludes files or turns the files rule off still has its files judged.
|
|
14
|
-
It fails when the repository tracks no
|
|
14
|
+
It fails when the repository tracks no `.ts`, `.tsx` or `.astro` file, since an empty scan would pass without judging anything.
|
|
15
15
|
It fails when the repository holds no Knip configuration, since Knip's default entries cannot tell a dead file from an entry point.
|
|
16
16
|
|
|
17
17
|
## What it reads
|
|
@@ -27,6 +27,7 @@ export default { ...base, entry: ["src/index.ts", "tests/**/*.test.ts"] };
|
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
The base in `knip-base.json` reports only unreferenced files.
|
|
30
|
+
In a repository that depends on `astro`, Knip reads `.astro` imports, so a file only an `.astro` entry imports counts as used and an `.astro` file no entry reaches is named.
|
|
30
31
|
The gate resolves the Knip binary from the installed kit, so a consumer installs nothing beyond the kit.
|
|
31
32
|
|
|
32
33
|
## Arguments
|
|
@@ -39,7 +40,7 @@ It takes none.
|
|
|
39
40
|
| --- | --- |
|
|
40
41
|
| 0 | no tracked file is unreferenced |
|
|
41
42
|
| 1 | a tracked file is unreferenced or the repository holds no Knip configuration |
|
|
42
|
-
| 2 | the repository tracks no TypeScript source, Knip cannot run, or its configuration does not parse |
|
|
43
|
+
| 2 | the repository tracks no TypeScript or Astro source, Knip cannot run, or its configuration does not parse |
|
|
43
44
|
|
|
44
45
|
## Sample output
|
|
45
46
|
|
|
@@ -51,12 +52,12 @@ unused: 1 unreferenced file(s):
|
|
|
51
52
|
A passing run counts the files it judged:
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
unused: no unreferenced files among 110 tracked .ts/.tsx file(s)
|
|
55
|
+
unused: no unreferenced files among 110 tracked .ts/.tsx/.astro file(s)
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
## When it runs
|
|
58
59
|
|
|
59
|
-
`checks-lint` runs it when the repository tracks a `.ts` or `.
|
|
60
|
+
`checks-lint` runs it when the repository tracks a `.ts`, `.tsx` or `.astro` file.
|
|
60
61
|
Such a repository names its entries in a Knip configuration.
|
|
61
62
|
|
|
62
63
|
## Related topics
|
package/oxlintrc.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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",
|
|
@@ -95,6 +98,7 @@
|
|
|
95
98
|
"checks-test": "src/testing/test.ts",
|
|
96
99
|
"checks-flake": "src/testing/flake.ts",
|
|
97
100
|
"checks-commit-identity": "src/delivery/commit-identity.ts",
|
|
101
|
+
"checks-mutation": "src/testing/mutation.ts",
|
|
98
102
|
"checks-mutation-compare": "src/testing/mutation-compare.ts",
|
|
99
103
|
"checks-subsumed-tests": "src/testing/subsumed-tests.ts",
|
|
100
104
|
"checks-ci-wiring": "src/delivery/ci-wiring.ts",
|
|
@@ -113,7 +117,9 @@
|
|
|
113
117
|
"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 scripts/doc-templates-write.ts && bun src/delivery/changelog-write.ts && bun scripts/doc-blocks-write.ts",
|
|
114
118
|
"lint": "oxlint --type-aware && bun src/core/lint.ts && depcruise --config .dependency-cruiser.cjs .",
|
|
115
119
|
"typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
|
|
116
|
-
"test": "bun src/testing/test.ts"
|
|
120
|
+
"test": "bun src/testing/test.ts",
|
|
121
|
+
"mutate": "bunx stryker run",
|
|
122
|
+
"mutate:incremental": "bunx stryker run --incremental"
|
|
117
123
|
},
|
|
118
124
|
"peerDependencies": {
|
|
119
125
|
"@swc/core": "1.16.2",
|
|
@@ -127,7 +133,9 @@
|
|
|
127
133
|
},
|
|
128
134
|
"devDependencies": {
|
|
129
135
|
"@effect/tsgo": "0.45.0",
|
|
136
|
+
"@hughescr/stryker-bun-runner": "1.4.0",
|
|
130
137
|
"@oxlint/plugins": "1.83.0",
|
|
138
|
+
"@stryker-mutator/core": "10.0.0",
|
|
131
139
|
"@swc/core": "1.16.2",
|
|
132
140
|
"@types/bun": "^1.3.0",
|
|
133
141
|
"dependency-cruiser": "18.4.0",
|
|
@@ -144,5 +152,9 @@
|
|
|
144
152
|
"@effect/platform-node-shared": "4.0.0-rc.115",
|
|
145
153
|
"@total-typescript/ts-reset": "0.6.1",
|
|
146
154
|
"knip": "6.38.0"
|
|
155
|
+
},
|
|
156
|
+
"overrides": {
|
|
157
|
+
"qs": "6.16.0",
|
|
158
|
+
"smol-toml": "1.9.0"
|
|
147
159
|
}
|
|
148
160
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import { TYPESCRIPT_SOURCE } from "../core/gates.ts";
|
|
3
4
|
import { checkoutFiles, commitOf, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
4
5
|
import { runMain } from "../core/main.ts";
|
|
5
6
|
import { knipReport, scanTree } from "./knip.ts";
|
|
@@ -127,7 +128,7 @@ export function report(head: Baseline, { unlisted, added, stale }: Drift): strin
|
|
|
127
128
|
: `${NAME}: ${head.length} unused export(s) in ${BASELINE_FILE}, and no new ones`;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
const scan = scanTree(NAME, INCLUDED).pipe(Effect.mapError((cause) => new ExportsError({ message: cause.message })));
|
|
131
|
+
const scan = scanTree(NAME, INCLUDED, TYPESCRIPT_SOURCE).pipe(Effect.mapError((cause) => new ExportsError({ message: cause.message })));
|
|
131
132
|
|
|
132
133
|
// Knip loads the configuration's imports, such as the kit's knip-base.json, from a node_modules the checkout lacks.
|
|
133
134
|
const unusedAt = Effect.fn("unusedAt")(
|
package/src/complexity/knip.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import type { TrackedContent } from "../core/gates.ts";
|
|
2
3
|
import { collect, git } from "../core/git.ts";
|
|
3
4
|
|
|
4
5
|
class KnipError extends Schema.TaggedError<KnipError>()("KnipError", {
|
|
@@ -16,8 +17,6 @@ const CONFIGS = [
|
|
|
16
17
|
"knip.config.ts",
|
|
17
18
|
] as const;
|
|
18
19
|
|
|
19
|
-
const TYPESCRIPT = ["*.ts", "*.tsx"];
|
|
20
|
-
|
|
21
20
|
const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
|
|
22
21
|
const fs = yield* FileSystem.FileSystem;
|
|
23
22
|
const path = yield* Path.Path;
|
|
@@ -55,13 +54,13 @@ export const knipReport = Effect.fn("knipReport")(function* (root: string, args:
|
|
|
55
54
|
return { kind: "reported", stdout: run.stdout } as const;
|
|
56
55
|
});
|
|
57
56
|
|
|
58
|
-
export const scanTree = Effect.fn("scanTree")(function* (name: string, args: readonly string[]) {
|
|
57
|
+
export const scanTree = Effect.fn("scanTree")(function* (name: string, args: readonly string[], source: TrackedContent) {
|
|
59
58
|
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
60
|
-
const tracked = (yield* git(["ls-files", "--", ...
|
|
59
|
+
const tracked = (yield* git(["ls-files", "--", ...source.pathspecs], root))
|
|
61
60
|
.split("\n")
|
|
62
61
|
.map((line) => line.trim())
|
|
63
62
|
.filter((line) => line !== "");
|
|
64
|
-
if (tracked.length === 0) return yield* new KnipError({ message:
|
|
63
|
+
if (tracked.length === 0) return yield* new KnipError({ message: `no tracked ${source.content} to scan` });
|
|
65
64
|
const reported = yield* knipReport(root, args);
|
|
66
65
|
if (reported.kind === "unconfigured") {
|
|
67
66
|
yield* Console.log(`${name}: no knip configuration names entry files, so add one extending the kit's knip-base.json`);
|
package/src/complexity/unused.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
+
import { LINTED_SOURCE } from "../core/gates.ts";
|
|
3
4
|
import { runMain } from "../core/main.ts";
|
|
4
5
|
import { scanTree } from "./knip.ts";
|
|
5
6
|
|
|
6
7
|
export type Scan = { readonly tracked: number; readonly files: readonly string[] };
|
|
7
8
|
|
|
8
9
|
const NAME = "unused";
|
|
9
|
-
const JUDGED =
|
|
10
|
+
const JUDGED = /[.]tsx?$|[.]astro$/;
|
|
10
11
|
|
|
11
12
|
class UnusedError extends Schema.TaggedError<UnusedError>()("UnusedError", {
|
|
12
13
|
message: Schema.String,
|
|
@@ -31,12 +32,12 @@ export const filesOf = Effect.fn("filesOf")(function* (stdout: string) {
|
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
export function report({ tracked, files }: Scan): string {
|
|
34
|
-
if (files.length === 0) return `${NAME}: no unreferenced files among ${tracked} tracked .ts/.tsx file(s)`;
|
|
35
|
+
if (files.length === 0) return `${NAME}: no unreferenced files among ${tracked} tracked .ts/.tsx/.astro file(s)`;
|
|
35
36
|
return [`${NAME}: ${files.length} unreferenced file(s):`, ...files.map((file) => ` ${file}`)].join("\n");
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const unused = Effect.gen(function* () {
|
|
39
|
-
const { tracked, reported } = yield* scanTree(NAME, ["--files"]).pipe(
|
|
40
|
+
const { tracked, reported } = yield* scanTree(NAME, ["--files"], LINTED_SOURCE).pipe(
|
|
40
41
|
Effect.mapError((cause) => new UnusedError({ message: cause.message })),
|
|
41
42
|
);
|
|
42
43
|
if (reported.kind === "unconfigured") return false;
|
package/src/core/gates.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type Program = {
|
|
|
3
3
|
readonly script: string;
|
|
4
4
|
};
|
|
5
5
|
|
|
6
|
-
type TrackedContent = {
|
|
6
|
+
export type TrackedContent = {
|
|
7
7
|
readonly pathspecs: readonly string[];
|
|
8
8
|
readonly content: string;
|
|
9
9
|
};
|
|
@@ -30,12 +30,14 @@ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "testing/
|
|
|
30
30
|
|
|
31
31
|
export const DEFAULT_BRANCH = "main";
|
|
32
32
|
|
|
33
|
-
const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
33
|
+
export const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
|
|
34
|
+
|
|
35
|
+
export const LINTED_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx", "*.astro"], content: "TypeScript or Astro source" };
|
|
34
36
|
|
|
35
37
|
const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
|
|
36
38
|
|
|
37
39
|
export const KIT_GATES = [
|
|
38
|
-
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo:
|
|
40
|
+
{ bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: LINTED_SOURCE },
|
|
39
41
|
{ bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
|
|
40
42
|
{ bin: "checks-commit-identity", vector: "delivery", file: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
41
43
|
{ bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
@@ -43,7 +45,7 @@ export const KIT_GATES = [
|
|
|
43
45
|
{ bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
44
46
|
{ bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
|
|
45
47
|
{ bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
46
|
-
{ bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo:
|
|
48
|
+
{ bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: LINTED_SOURCE },
|
|
47
49
|
{ bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
48
50
|
{ bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
49
51
|
{ bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
|
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;
|
package/src/docs/docs.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
3
|
import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
|
|
4
4
|
import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
5
|
-
import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
|
|
5
|
+
import { ADR_DIRECTORY, judge, placementOf, placementProblem, recordsOf, speaksToConsumers, type Placement, type Records } from "./doc-rules.ts";
|
|
6
6
|
import { vanishedNames } from "./doc-names.ts";
|
|
7
7
|
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
8
8
|
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
@@ -40,7 +40,7 @@ const NAME = "docs";
|
|
|
40
40
|
const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
|
|
41
41
|
const MARKDOWN = [":(glob)**/*.md"];
|
|
42
42
|
|
|
43
|
-
function templateFindings(path: string, text: string, placement: Placement, records:
|
|
43
|
+
function templateFindings(path: string, text: string, placement: Placement, records: Records): readonly Finding[] {
|
|
44
44
|
const misplaced = placementProblem(placement);
|
|
45
45
|
if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
|
|
46
46
|
if (placement.type !== "judged") return [];
|
|
@@ -91,13 +91,13 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
91
91
|
const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
|
|
92
92
|
const changed = yield* changedLines(base, head, MARKDOWN, root);
|
|
93
93
|
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
94
|
-
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
95
94
|
const proseDocs = present.flatMap((path) => {
|
|
96
95
|
const reader = readerOf(path);
|
|
97
96
|
return reader === undefined ? [] : [{ path, reader }];
|
|
98
97
|
});
|
|
99
98
|
const texts = yield* readTexts(root, head, [...new Set([...present.filter((path) => path.endsWith(".md")), ...proseDocs.map(({ path }) => path)])]);
|
|
100
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) })));
|
|
101
101
|
const judged = present.map((path) => ({ path, placement: placementOf(path, text(path)) })).filter(({ placement }) => placement.type !== "unjudged");
|
|
102
102
|
|
|
103
103
|
const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# lint-coverage: fail when oxlint silently skips a tracked TypeScript source,
|
|
2
|
+
# lint-coverage: fail when oxlint silently skips a tracked TypeScript or Astro source,
|
|
3
3
|
# or when tsconfig.json's program silently drops the ts-reset rules.
|
|
4
4
|
set -eu
|
|
5
5
|
|
|
6
6
|
tmp="$(mktemp -d)"
|
|
7
7
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
|
8
8
|
|
|
9
|
-
git ls-files -- '*.ts' '*.tsx' | LC_ALL=C sort > "$tmp/expected"
|
|
9
|
+
git ls-files -- '*.ts' '*.tsx' '*.astro' | LC_ALL=C sort > "$tmp/expected"
|
|
10
10
|
if ! [ -s "$tmp/expected" ]; then
|
|
11
|
-
echo "lint-coverage: no tracked .ts/.tsx files"
|
|
11
|
+
echo "lint-coverage: no tracked .ts/.tsx/.astro files"
|
|
12
12
|
exit 0
|
|
13
13
|
fi
|
|
14
14
|
|
|
@@ -18,7 +18,7 @@ if ! oxlint --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
|
|
|
18
18
|
cat "$tmp/walk" "$tmp/walk-error"
|
|
19
19
|
exit 2
|
|
20
20
|
fi
|
|
21
|
-
grep -E '
|
|
21
|
+
grep -E '[.]tsx?$|[.]astro$' "$tmp/walk" | LC_ALL=C sort > "$tmp/walked" || true
|
|
22
22
|
|
|
23
23
|
expected_count="$(wc -l < "$tmp/expected" | tr -d ' ')"
|
|
24
24
|
walked_count="$(grep -c . "$tmp/walked" || true)"
|
|
@@ -27,11 +27,16 @@ status=0
|
|
|
27
27
|
comm -23 "$tmp/expected" "$tmp/walked" > "$tmp/missing"
|
|
28
28
|
if [ -s "$tmp/missing" ]; then
|
|
29
29
|
missing_count="$(wc -l < "$tmp/missing" | tr -d ' ')"
|
|
30
|
-
echo "lint-coverage: oxlint skips ${missing_count}/${expected_count} tracked .ts/.tsx files; missing:"
|
|
30
|
+
echo "lint-coverage: oxlint skips ${missing_count}/${expected_count} tracked .ts/.tsx/.astro files; missing:"
|
|
31
31
|
cat "$tmp/missing"
|
|
32
32
|
status=1
|
|
33
33
|
else
|
|
34
|
-
echo "lint-coverage: ${walked_count}/${expected_count} tracked .ts/.tsx files"
|
|
34
|
+
echo "lint-coverage: ${walked_count}/${expected_count} tracked .ts/.tsx/.astro files"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if ! grep -qE '[.]tsx?$' "$tmp/expected"; then
|
|
38
|
+
echo "lint-coverage: no tracked .ts/.tsx files, so no program to hold the ts-reset rules"
|
|
39
|
+
exit "$status"
|
|
35
40
|
fi
|
|
36
41
|
|
|
37
42
|
if ! [ -f tsconfig.json ]; then
|
|
@@ -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/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,
|