@avi2dg/checks 0.4.0 → 0.6.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/README.md +99 -4
- package/dist/index.js +43 -1
- package/package.json +4 -1
- package/scripts/suppressions-ratchet.ts +142 -0
package/README.md
CHANGED
|
@@ -5,9 +5,10 @@ Deterministic checks shared across my TypeScript repos. One package,
|
|
|
5
5
|
Effect language-service block, the shared commitlint config, the shared
|
|
6
6
|
dependency-cruiser base, the test-layout check with its bunfig preset,
|
|
7
7
|
the commit-identity check with its workflow, the comment gate with its
|
|
8
|
-
workflow and backtest, the
|
|
9
|
-
|
|
10
|
-
error-channel plugin compiled to
|
|
8
|
+
workflow and backtest, the oxlint suppressions ratchet, the Stryker
|
|
9
|
+
mutation-testing preset with its no-regression comparator, the
|
|
10
|
+
CI-wiring check, and the Effect error-channel plugin compiled to
|
|
11
|
+
JavaScript.
|
|
11
12
|
|
|
12
13
|
Published as `@avi2dg/checks` on the public npm registry.
|
|
13
14
|
|
|
@@ -80,6 +81,48 @@ export default {
|
|
|
80
81
|
The registry version is pinned by the consumer's lockfile; bump
|
|
81
82
|
`@avi2dg/checks` to adopt a new release.
|
|
82
83
|
|
|
84
|
+
## Effect rules
|
|
85
|
+
|
|
86
|
+
The base config loads the `effect-channel` plugin and turns on
|
|
87
|
+
`effect-channel/no-error-channel-escape`, which refuses `Effect.ignore`,
|
|
88
|
+
`Effect.ignoreCause`, the `Effect.catchCause` family, and an
|
|
89
|
+
`Effect.catch` whose handler takes no error or names it `_`.
|
|
90
|
+
|
|
91
|
+
Two more rules ship off, because a repository writes only some of its
|
|
92
|
+
paths in Effect: code a host loads without `node_modules`, such as a
|
|
93
|
+
hook bundle or this oxlint plugin, cannot import it.
|
|
94
|
+
|
|
95
|
+
- `effect-channel/no-throw` refuses a `throw` statement.
|
|
96
|
+
- `effect-channel/no-try-catch` refuses a `try` statement with a
|
|
97
|
+
`catch` clause. `try`/`finally` stays allowed.
|
|
98
|
+
|
|
99
|
+
Each refusal says what to write instead: a `Schema.TaggedError` failed
|
|
100
|
+
through `Effect.fail`, a throwing call wrapped in `Effect.try` or
|
|
101
|
+
`Effect.tryPromise`, and recovery by tag with `Effect.catchTag`.
|
|
102
|
+
|
|
103
|
+
A repository turns them on for the paths it writes in Effect through an
|
|
104
|
+
`overrides` entry in its root `.oxlintrc.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"extends": ["./node_modules/@avi2dg/checks/oxlintrc.json"],
|
|
109
|
+
"plugins": ["typescript", "oxc", "eslint", "import"],
|
|
110
|
+
"overrides": [
|
|
111
|
+
{
|
|
112
|
+
"files": ["src/**"],
|
|
113
|
+
"rules": {
|
|
114
|
+
"effect-channel/no-throw": "error",
|
|
115
|
+
"effect-channel/no-try-catch": "error"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
oxlint resolves `files` against the directory of the config that holds
|
|
123
|
+
the override, so a config passed with `-c` from outside the repository
|
|
124
|
+
matches nothing and reports nothing.
|
|
125
|
+
|
|
83
126
|
## Test layout
|
|
84
127
|
|
|
85
128
|
`checks-test-layout` fails unless the repo holds this shape, and names the
|
|
@@ -305,6 +348,58 @@ jobs:
|
|
|
305
348
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
306
349
|
```
|
|
307
350
|
|
|
351
|
+
## Suppressions ratchet
|
|
352
|
+
|
|
353
|
+
`scripts/suppressions-ratchet.ts` holds oxlint's bulk-suppression
|
|
354
|
+
baseline, `oxlint-suppressions.json`, to counts that only fall. oxlint
|
|
355
|
+
accepts whatever `oxlint --suppress-all` writes, so raising a count to
|
|
356
|
+
let a new site through passes the lint. The ratchet reads the file at a
|
|
357
|
+
base and a head and fails naming each file and rule whose count rose or
|
|
358
|
+
that appeared:
|
|
359
|
+
|
|
360
|
+
```sh
|
|
361
|
+
bun run checks-suppressions-ratchet <base-ref> <head-ref>
|
|
362
|
+
bun run checks-suppressions-ratchet <ref>
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
With one argument it compares that commit with its parent, and exits 2
|
|
366
|
+
when the parent is not in the clone. With two it reads the base where
|
|
367
|
+
the head branched off, at their merge-base, so a count the base branch
|
|
368
|
+
lowered since does not read as a rise at the head. A count that fell and
|
|
369
|
+
an entry that went both pass. A commit without the file counts as empty,
|
|
370
|
+
so the commit that first adds a baseline fails with every entry
|
|
371
|
+
appearing. The file is read from the directory the command runs in,
|
|
372
|
+
which is where oxlint writes it. It exits 2 when a ref does not resolve
|
|
373
|
+
or the file is not oxlint's count per rule per file:
|
|
374
|
+
|
|
375
|
+
```
|
|
376
|
+
suppressions-ratchet: 2 count(s) in oxlint-suppressions.json rose or appeared; fix the site instead of suppressing it:
|
|
377
|
+
src/added.ts typescript/no-unsafe-type-assertion appeared with 1
|
|
378
|
+
src/dispatch.ts typescript/no-non-null-assertion rose from 12 to 13
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Enforcement runs on pull requests, where the range from the base branch's
|
|
382
|
+
current tip to the head is visible:
|
|
383
|
+
|
|
384
|
+
```yaml
|
|
385
|
+
on:
|
|
386
|
+
pull_request:
|
|
387
|
+
types: [opened, edited, synchronize, reopened]
|
|
388
|
+
jobs:
|
|
389
|
+
suppressions-ratchet:
|
|
390
|
+
runs-on: ubuntu-latest
|
|
391
|
+
steps:
|
|
392
|
+
- uses: actions/checkout@v5
|
|
393
|
+
with:
|
|
394
|
+
fetch-depth: 0
|
|
395
|
+
- uses: oven-sh/setup-bun@v2
|
|
396
|
+
- run: bun install --frozen-lockfile
|
|
397
|
+
- run: bunx checks-suppressions-ratchet "origin/$BASE_REF" "$HEAD_SHA"
|
|
398
|
+
env:
|
|
399
|
+
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
400
|
+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
401
|
+
```
|
|
402
|
+
|
|
308
403
|
## CI wiring
|
|
309
404
|
|
|
310
405
|
`checks-ci-wiring` fails when a command the repository's CI must run no
|
|
@@ -527,7 +622,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
|
|
|
527
622
|
and tests before it publishes:
|
|
528
623
|
|
|
529
624
|
```sh
|
|
530
|
-
git tag v0.
|
|
625
|
+
git tag v0.6.0 && git push origin v0.6.0
|
|
531
626
|
```
|
|
532
627
|
|
|
533
628
|
The `release` workflow publishes the tagged version through npm
|
package/dist/index.js
CHANGED
|
@@ -69,10 +69,52 @@ var rule = {
|
|
|
69
69
|
};
|
|
70
70
|
var no_error_channel_escape_default = rule;
|
|
71
71
|
|
|
72
|
+
// effect-channel/no-throw.ts
|
|
73
|
+
var rule2 = {
|
|
74
|
+
meta: {
|
|
75
|
+
type: "problem",
|
|
76
|
+
docs: { description: "Disallow throw, which fails outside Effect's error channel" }
|
|
77
|
+
},
|
|
78
|
+
create(context) {
|
|
79
|
+
return {
|
|
80
|
+
ThrowStatement(node) {
|
|
81
|
+
context.report({
|
|
82
|
+
node,
|
|
83
|
+
message: "throw escapes the error channel: no type records the failure, so no caller has to answer for it. Define the failure with Schema.TaggedError and fail with it through Effect.fail, so it stays in E for Effect.catchTag to handle"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var no_throw_default = rule2;
|
|
90
|
+
|
|
91
|
+
// effect-channel/no-try-catch.ts
|
|
92
|
+
var rule3 = {
|
|
93
|
+
meta: {
|
|
94
|
+
type: "problem",
|
|
95
|
+
docs: { description: "Disallow a try statement with a catch clause, which recovers outside Effect's error channel" }
|
|
96
|
+
},
|
|
97
|
+
create(context) {
|
|
98
|
+
return {
|
|
99
|
+
CatchClause(node) {
|
|
100
|
+
context.report({
|
|
101
|
+
node,
|
|
102
|
+
message: "catch recovers outside the error channel: it takes whatever was thrown as unknown, bugs included. Wrap the throwing call in Effect.try or Effect.tryPromise, whose catch maps the cause to a Schema.TaggedError, and recover by tag with Effect.catchTag"
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var no_try_catch_default = rule3;
|
|
109
|
+
|
|
72
110
|
// effect-channel/index.ts
|
|
73
111
|
var plugin = {
|
|
74
112
|
meta: { name: "effect-channel" },
|
|
75
|
-
rules: {
|
|
113
|
+
rules: {
|
|
114
|
+
"no-error-channel-escape": no_error_channel_escape_default,
|
|
115
|
+
"no-throw": no_throw_default,
|
|
116
|
+
"no-try-catch": no_try_catch_default
|
|
117
|
+
}
|
|
76
118
|
};
|
|
77
119
|
var effect_channel_default = plugin;
|
|
78
120
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"scripts/ci-wiring.ts",
|
|
23
23
|
"scripts/comments.ts",
|
|
24
24
|
"scripts/comment-gate.ts",
|
|
25
|
+
"scripts/suppressions-ratchet.ts",
|
|
25
26
|
"scripts/backtest.ts",
|
|
26
27
|
"oxlintrc.json",
|
|
27
28
|
"stryker.preset.js",
|
|
@@ -39,6 +40,7 @@
|
|
|
39
40
|
"./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
|
|
40
41
|
"./scripts/comments.ts": "./scripts/comments.ts",
|
|
41
42
|
"./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
|
|
43
|
+
"./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
|
|
42
44
|
"./scripts/backtest.ts": "./scripts/backtest.ts",
|
|
43
45
|
"./oxlintrc.json": "./oxlintrc.json",
|
|
44
46
|
"./stryker.preset.js": "./stryker.preset.js",
|
|
@@ -52,6 +54,7 @@
|
|
|
52
54
|
"checks-mutation-compare": "scripts/mutation-compare.ts",
|
|
53
55
|
"checks-ci-wiring": "scripts/ci-wiring.ts",
|
|
54
56
|
"checks-comment-gate": "scripts/comment-gate.ts",
|
|
57
|
+
"checks-suppressions-ratchet": "scripts/suppressions-ratchet.ts",
|
|
55
58
|
"checks-backtest": "scripts/backtest.ts"
|
|
56
59
|
},
|
|
57
60
|
"scripts": {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
export const SUPPRESSIONS = "oxlint-suppressions.json";
|
|
3
|
+
|
|
4
|
+
export type Suppressions = ReadonlyMap<string, ReadonlyMap<string, number>>;
|
|
5
|
+
|
|
6
|
+
export type Rise =
|
|
7
|
+
| { readonly kind: "rose"; readonly file: string; readonly rule: string; readonly base: number; readonly head: number }
|
|
8
|
+
| { readonly kind: "appeared"; readonly file: string; readonly rule: string; readonly head: number };
|
|
9
|
+
|
|
10
|
+
export type Ratchet = {
|
|
11
|
+
readonly counted: number;
|
|
12
|
+
readonly lowered: number;
|
|
13
|
+
readonly rises: readonly Rise[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class SuppressionsError extends Error {}
|
|
17
|
+
|
|
18
|
+
const USAGE = "usage: suppressions-ratchet.ts <ref> | <base-ref> <head-ref>";
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isCount(value: unknown): value is number {
|
|
25
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseSuppressions(text: string, where: string): Suppressions {
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new SuppressionsError(`suppressions-ratchet: ${where} is not valid JSON`);
|
|
34
|
+
}
|
|
35
|
+
if (!isRecord(parsed)) {
|
|
36
|
+
throw new SuppressionsError(`suppressions-ratchet: ${where} is not an object of files`);
|
|
37
|
+
}
|
|
38
|
+
const files = new Map<string, ReadonlyMap<string, number>>();
|
|
39
|
+
for (const [file, rules] of Object.entries(parsed)) {
|
|
40
|
+
if (!isRecord(rules)) {
|
|
41
|
+
throw new SuppressionsError(`suppressions-ratchet: ${where} holds ${file} without an object of rules`);
|
|
42
|
+
}
|
|
43
|
+
const counts = new Map<string, number>();
|
|
44
|
+
for (const [rule, entry] of Object.entries(rules)) {
|
|
45
|
+
if (!isRecord(entry) || !isCount(entry["count"])) {
|
|
46
|
+
throw new SuppressionsError(`suppressions-ratchet: ${where} holds ${file} ${rule} without a whole count`);
|
|
47
|
+
}
|
|
48
|
+
counts.set(rule, entry["count"]);
|
|
49
|
+
}
|
|
50
|
+
files.set(file, counts);
|
|
51
|
+
}
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
|
|
56
|
+
const rises: Rise[] = [];
|
|
57
|
+
let counted = 0;
|
|
58
|
+
for (const [file, rules] of head) {
|
|
59
|
+
for (const [rule, count] of rules) {
|
|
60
|
+
counted += 1;
|
|
61
|
+
const before = base.get(file)?.get(rule);
|
|
62
|
+
if (before === undefined) {
|
|
63
|
+
if (count > 0) rises.push({ kind: "appeared", file, rule, head: count });
|
|
64
|
+
} else if (count > before) {
|
|
65
|
+
rises.push({ kind: "rose", file, rule, base: before, head: count });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let lowered = 0;
|
|
70
|
+
for (const [file, rules] of base) {
|
|
71
|
+
for (const [rule, count] of rules) {
|
|
72
|
+
if ((head.get(file)?.get(rule) ?? 0) < count) lowered += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
76
|
+
return { counted, lowered, rises };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describeRise(rise: Rise): string {
|
|
80
|
+
const change = rise.kind === "rose" ? `rose from ${rise.base} to ${rise.head}` : `appeared with ${rise.head}`;
|
|
81
|
+
return `${rise.file} ${rise.rule} ${change}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function report({ counted, lowered, rises }: Ratchet): string {
|
|
85
|
+
if (rises.length === 0) {
|
|
86
|
+
return `suppressions-ratchet: no count in ${SUPPRESSIONS} rose or appeared (${counted} at the head, ${lowered} lowered)`;
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
`suppressions-ratchet: ${rises.length} count(s) in ${SUPPRESSIONS} rose or appeared; fix the site instead of suppressing it:`,
|
|
90
|
+
...rises.map((rise) => ` ${describeRise(rise)}`),
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function die(message: string): never {
|
|
95
|
+
console.error(message);
|
|
96
|
+
process.exit(2);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function git(...args: readonly string[]): string {
|
|
100
|
+
const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
101
|
+
if (!result.success) {
|
|
102
|
+
die(`suppressions-ratchet: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
|
|
103
|
+
}
|
|
104
|
+
return result.stdout.toString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function commitOf(rev: string): string {
|
|
108
|
+
return git("rev-parse", "--verify", `${rev}^{commit}`).trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mergeBase(base: string, head: string): string {
|
|
112
|
+
const result = Bun.spawnSync(["git", "merge-base", base, head], { stdout: "pipe", stderr: "pipe" });
|
|
113
|
+
if (!result.success) die(`suppressions-ratchet: ${base} and ${head} share no commit`);
|
|
114
|
+
return result.stdout.toString().trim();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function suppressionsAt(commit: string): Suppressions {
|
|
118
|
+
const blob = git("ls-tree", "--object-only", commit, "--", SUPPRESSIONS).trim();
|
|
119
|
+
if (blob === "") return new Map();
|
|
120
|
+
try {
|
|
121
|
+
return parseSuppressions(git("cat-file", "blob", blob), `${SUPPRESSIONS} at ${commit}`);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return die(error instanceof SuppressionsError ? error.message : `suppressions-ratchet: ${String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function runRange(base: string, head: string): Ratchet {
|
|
128
|
+
const headCommit = commitOf(head);
|
|
129
|
+
// At the base tip, a count the base branch lowered after the head branched off would read as a rise at the head.
|
|
130
|
+
const branchPoint = mergeBase(commitOf(base), headCommit);
|
|
131
|
+
return compareSuppressions(suppressionsAt(branchPoint), suppressionsAt(headCommit));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) {
|
|
135
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
136
|
+
if (first === undefined || extra.length > 0) die(USAGE);
|
|
137
|
+
|
|
138
|
+
const ratchet = second !== undefined ? runRange(first, second) : runRange(`${first}^`, first);
|
|
139
|
+
|
|
140
|
+
console.log(report(ratchet));
|
|
141
|
+
process.exit(ratchet.rises.length === 0 ? 0 : 1);
|
|
142
|
+
}
|