@avi2dg/checks 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -2
- package/{commitlint.config.js → commitlint.config.ts} +1 -1
- package/dist/mutation/mutation-guard-plugin.js +40 -0
- package/dist/mutation/mutation-refusal.js +23 -0
- package/docs/configs/commit-messages.md +2 -2
- package/docs/design.md +20 -0
- package/docs/gates/checks-ci-wiring.md +3 -2
- package/docs/gates/checks-copy.md +113 -0
- package/docs/gates/checks-docs-only.md +3 -2
- package/docs/gates/checks-docs.md +3 -0
- package/docs/gates/checks-flake.md +49 -1
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-baseline.md +4 -2
- package/docs/gates/checks-mutation-compare.md +33 -8
- package/docs/gates/checks-mutation-scope.md +140 -0
- package/docs/gates/checks-mutation.md +11 -7
- package/docs/gates/checks-no-plain-js.md +60 -0
- package/docs/gates/checks-test-layout.md +3 -2
- package/docs/gates/checks-test.md +35 -6
- package/package.json +18 -6
- package/src/core/declared-inputs.ts +76 -0
- package/src/core/gates.ts +4 -0
- package/src/core/git.ts +9 -0
- package/src/delivery/ci-wiring.ts +25 -11
- package/src/delivery/commit-title.ts +2 -2
- package/src/docs/copy-html.ts +110 -0
- package/src/docs/copy-text.ts +70 -0
- package/src/docs/copy.ts +70 -0
- package/src/docs/prose-matchers.ts +14 -14
- package/src/quality/frontend-syntax.ts +9 -52
- package/src/quality/no-plain-js.ts +71 -0
- package/src/testing/flake-preload.ts +4 -0
- package/src/testing/flake.ts +142 -16
- package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
- package/src/testing/mutation-scope.ts +354 -0
- package/src/testing/mutation.ts +1 -1
- package/src/testing/test-layout.ts +8 -15
- package/src/testing/test-report.ts +20 -5
- package/src/testing/test-skips.ts +1 -1
- package/src/testing/test.ts +53 -11
- package/stryker.preset.js +3 -3
- package/src/testing/mutation-guard-plugin.js +0 -13
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
-
import {
|
|
3
|
+
import { workingTreeFiles } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { ENTRY_POINT, TEST_ENTRY_POINT } from "../core/gates.ts";
|
|
6
6
|
import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
|
|
7
|
+
import { scriptOptions } from "./test.ts";
|
|
7
8
|
|
|
8
9
|
export type Violation = {
|
|
9
10
|
readonly file: string;
|
|
@@ -60,7 +61,7 @@ const ACCEPTED_IGNORES: readonly (readonly string[])[] = [
|
|
|
60
61
|
export const LAYOUT_CHECK_MARK = "src/testing/test-layout.ts";
|
|
61
62
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
62
63
|
const OWN_ENTRY_POINT = `src/${ENTRY_POINT.script}`;
|
|
63
|
-
const
|
|
64
|
+
const PARALLEL_OPTION = "optionally with --parallel=<workers>";
|
|
64
65
|
|
|
65
66
|
export class LayoutError extends Schema.TaggedError<LayoutError>()("LayoutError", {
|
|
66
67
|
message: Schema.String,
|
|
@@ -236,11 +237,11 @@ function tierScriptViolation(scripts: unknown, files: readonly string[], tier: (
|
|
|
236
237
|
const expected = `${TEST_ENTRY_POINT.bin} --tier=${tier}`;
|
|
237
238
|
const hasTests = files.some((candidate) => candidate.startsWith(`tests/${tier}/`) && TEST_FILE.test(candidate));
|
|
238
239
|
if (!hasTests && found === undefined) return undefined;
|
|
239
|
-
if (found ===
|
|
240
|
+
if (scriptOptions(found)?.tier === tier) return undefined;
|
|
240
241
|
return {
|
|
241
242
|
file,
|
|
242
243
|
line: undefined,
|
|
243
|
-
message: `${key} must be "${expected}"${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
|
|
244
|
+
message: `${key} must be "${expected}", ${PARALLEL_OPTION},${hasTests ? " when its test tier has files" : " when declared"}, found ${JSON.stringify(found ?? null)}`,
|
|
244
245
|
};
|
|
245
246
|
}
|
|
246
247
|
|
|
@@ -250,11 +251,12 @@ export function scriptViolations(manifest: unknown, files: readonly string[] = [
|
|
|
250
251
|
const test = isRecord(scripts) ? scripts["test"] : undefined;
|
|
251
252
|
const lint = isRecord(scripts) ? scripts["lint"] : undefined;
|
|
252
253
|
const violations: Violation[] = [];
|
|
253
|
-
|
|
254
|
+
const options = scriptOptions(test);
|
|
255
|
+
if (options === undefined || options.tier !== undefined) {
|
|
254
256
|
violations.push({
|
|
255
257
|
file,
|
|
256
258
|
line: undefined,
|
|
257
|
-
message: `scripts.test must be
|
|
259
|
+
message: `scripts.test must be "${TEST_ENTRY_POINT.bin}", ${PARALLEL_OPTION}, which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
|
|
258
260
|
});
|
|
259
261
|
}
|
|
260
262
|
if (typeof lint !== "string" || !runsLayoutCheck(lint)) {
|
|
@@ -299,15 +301,6 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
|
|
|
299
301
|
return violations;
|
|
300
302
|
}
|
|
301
303
|
|
|
302
|
-
const workingTreeFiles = Effect.fn("workingTreeFiles")(function* (root: string) {
|
|
303
|
-
const fs = yield* FileSystem.FileSystem;
|
|
304
|
-
const path = yield* Path.Path;
|
|
305
|
-
const listed = yield* git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root);
|
|
306
|
-
return yield* Effect.filter(listed.split("\0").filter(Boolean), (file) => fs.exists(path.join(root, file)), {
|
|
307
|
-
concurrency: "unbounded",
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
|
|
311
304
|
const parsedToml = Effect.fn("parsedToml")(function* (file: string) {
|
|
312
305
|
const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
|
|
313
306
|
return yield* Effect.try({
|
|
@@ -9,6 +9,11 @@ export type TestResult = {
|
|
|
9
9
|
readonly outcome: Outcome;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
type Report = {
|
|
13
|
+
readonly results: readonly TestResult[];
|
|
14
|
+
readonly crashedFiles: readonly string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
12
17
|
export class ReportError extends Schema.TaggedError<ReportError>()("ReportError", {
|
|
13
18
|
message: Schema.String,
|
|
14
19
|
}) {}
|
|
@@ -36,6 +41,7 @@ const decodeSuite = Schema.decodeUnknownEffect(Schema.Struct({ name: Schema.Stri
|
|
|
36
41
|
const decodeCase = Schema.decodeUnknownEffect(
|
|
37
42
|
Schema.Struct({ name: Schema.String, file: Schema.String, line: Schema.NumberFromString.check(Schema.isInt()) }),
|
|
38
43
|
);
|
|
44
|
+
const decodeCrash = Schema.decodeUnknownEffect(Schema.Struct({ file: Schema.String }));
|
|
39
45
|
|
|
40
46
|
function unescape(text: string): string {
|
|
41
47
|
return text.replace(ENTITY, (_, named: string | undefined, decimal: string | undefined, hex: string | undefined) => {
|
|
@@ -91,20 +97,28 @@ function outcomeOf(testcase: Element): Outcome {
|
|
|
91
97
|
return testcase.children.some((child) => child.tag === "failure" || child.tag === "error") ? "failed" : "passed";
|
|
92
98
|
}
|
|
93
99
|
|
|
100
|
+
// bun's parallel coordinator replaces every result of a file whose worker crashed with one failure that has no line.
|
|
101
|
+
function crashed(testcase: Element): boolean {
|
|
102
|
+
return testcase.attributes["line"] === undefined && outcomeOf(testcase) === "failed";
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
const collect = Effect.fnUntraced(function* (
|
|
95
106
|
suite: Element,
|
|
96
107
|
describes: readonly string[],
|
|
97
|
-
results: TestResult[],
|
|
108
|
+
report: { readonly results: TestResult[]; readonly crashedFiles: string[] },
|
|
98
109
|
): Effect.fn.Return<void, ReportError> {
|
|
99
110
|
const unreadable = (tag: string) => (cause: { readonly message: string }) =>
|
|
100
111
|
new ReportError({ message: `the junit report holds a <${tag}> bun never writes: ${cause.message}` });
|
|
101
112
|
for (const child of suite.children) {
|
|
102
113
|
if (child.tag === "testsuite") {
|
|
103
114
|
const { name } = yield* decodeSuite(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
104
|
-
yield* collect(child, [...describes, name],
|
|
115
|
+
yield* collect(child, [...describes, name], report);
|
|
116
|
+
} else if (child.tag === "testcase" && crashed(child)) {
|
|
117
|
+
const { file } = yield* decodeCrash(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
118
|
+
report.crashedFiles.push(file);
|
|
105
119
|
} else if (child.tag === "testcase") {
|
|
106
120
|
const { name, file, line } = yield* decodeCase(child.attributes).pipe(Effect.mapError(unreadable(child.tag)));
|
|
107
|
-
results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
|
|
121
|
+
report.results.push({ file, name: [...describes, name].join(NAME_SEPARATOR), line, outcome: outcomeOf(child) });
|
|
108
122
|
}
|
|
109
123
|
}
|
|
110
124
|
});
|
|
@@ -113,8 +127,9 @@ const collect = Effect.fnUntraced(function* (
|
|
|
113
127
|
export const parseReport = Effect.fn("parseReport")(function* (xml: string) {
|
|
114
128
|
const root = yield* parseElements(xml);
|
|
115
129
|
const results: TestResult[] = [];
|
|
130
|
+
const crashedFiles: string[] = [];
|
|
116
131
|
for (const fileSuite of root.children) {
|
|
117
|
-
if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], results);
|
|
132
|
+
if (fileSuite.tag === "testsuite") yield* collect(fileSuite, [], { results, crashedFiles });
|
|
118
133
|
}
|
|
119
|
-
return results;
|
|
134
|
+
return { results, crashedFiles } satisfies Report;
|
|
120
135
|
});
|
|
@@ -2,7 +2,7 @@ import { Effect, FileSystem, Path, Schema } from "effect";
|
|
|
2
2
|
import { identifierName, isRecord, lineOf, parseTypeScript, spanStart, stringValue } from "../core/swc.ts";
|
|
3
3
|
|
|
4
4
|
export type Environment = "ci" | "local";
|
|
5
|
-
export type TestTier = "live" | "pixel";
|
|
5
|
+
export type TestTier = "live" | "pixel" | "unit" | "e2e";
|
|
6
6
|
export type SkipDeclaration = {
|
|
7
7
|
readonly file: string;
|
|
8
8
|
readonly line: number;
|
package/src/testing/test.ts
CHANGED
|
@@ -20,12 +20,45 @@ export class SkipGateError extends Schema.TaggedError<SkipGateError>()("SkipGate
|
|
|
20
20
|
message: Schema.String,
|
|
21
21
|
}) {}
|
|
22
22
|
|
|
23
|
+
export type TestOptions = {
|
|
24
|
+
readonly tier?: TestTier;
|
|
25
|
+
readonly workers?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
23
28
|
const NAME = TEST_ENTRY_POINT.bin;
|
|
24
|
-
const USAGE = `usage: ${NAME}
|
|
29
|
+
const USAGE = `usage: ${NAME} [--tier=live | --tier=pixel | --tier=unit | --tier=e2e] [--parallel=<workers>], and it refuses test filters`;
|
|
25
30
|
const TIERS = new Map<string, TestTier>([
|
|
26
31
|
["--tier=live", "live"],
|
|
27
32
|
["--tier=pixel", "pixel"],
|
|
33
|
+
["--tier=unit", "unit"],
|
|
34
|
+
["--tier=e2e", "e2e"],
|
|
28
35
|
]);
|
|
36
|
+
const PARALLEL = /^--parallel=([1-9][0-9]*)$/;
|
|
37
|
+
|
|
38
|
+
function optionOf(arg: string): TestOptions | undefined {
|
|
39
|
+
const tier = TIERS.get(arg);
|
|
40
|
+
if (tier !== undefined) return { tier };
|
|
41
|
+
const workers = PARALLEL.exec(arg)?.[1];
|
|
42
|
+
return workers === undefined ? undefined : { workers: Number(workers) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function testOptions(args: readonly string[]): TestOptions | undefined {
|
|
46
|
+
let options: TestOptions = {};
|
|
47
|
+
for (const arg of args) {
|
|
48
|
+
const option = optionOf(arg);
|
|
49
|
+
if (option === undefined || Object.keys(option).some((key) => key in options)) return undefined;
|
|
50
|
+
options = { ...options, ...option };
|
|
51
|
+
}
|
|
52
|
+
return options;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TEST_SCRIPTS: readonly string[] = [NAME, `bun src/${TEST_ENTRY_POINT.script}`];
|
|
56
|
+
|
|
57
|
+
export function scriptOptions(script: unknown): TestOptions | undefined {
|
|
58
|
+
if (typeof script !== "string") return undefined;
|
|
59
|
+
const entry = TEST_SCRIPTS.find((candidate) => script === candidate || script.startsWith(`${candidate} `));
|
|
60
|
+
return entry === undefined ? undefined : testOptions(script.slice(entry.length).split(" ").filter((arg) => arg !== ""));
|
|
61
|
+
}
|
|
29
62
|
|
|
30
63
|
function matches(declaration: SkipDeclaration, result: TestResult): boolean {
|
|
31
64
|
const { name } = declaration;
|
|
@@ -113,10 +146,17 @@ const environmentOf = Config.Boolean("CI").pipe(
|
|
|
113
146
|
Effect.mapError((cause) => new SkipGateError({ message: `cannot tell a ci run from a local one: ${cause.message}` })),
|
|
114
147
|
);
|
|
115
148
|
|
|
116
|
-
|
|
149
|
+
function tierArgs(tier: TestTier | undefined): readonly string[] {
|
|
150
|
+
if (tier === undefined) return [];
|
|
151
|
+
// The preset ignores tests/live and tests/pixel, so only those tiers clear the ignore list.
|
|
152
|
+
if (tier === "live" || tier === "pixel") return ["--path-ignore-patterns", "", `./tests/${tier}`];
|
|
153
|
+
return [`./tests/${tier}`];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const runSuite = Effect.fn("runSuite")(function* (outfile: string, { tier, workers }: TestOptions) {
|
|
117
157
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
118
|
-
const
|
|
119
|
-
const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
|
|
158
|
+
const parallelArgs = workers === undefined ? [] : [`--parallel=${workers}`];
|
|
159
|
+
const args = ["test", "--randomize", ...parallelArgs, ...tierArgs(tier), ...reporterArgs(outfile)];
|
|
120
160
|
return yield* spawner.exitCode(
|
|
121
161
|
ChildProcess.make(process.execPath, args, {
|
|
122
162
|
stdin: "ignore",
|
|
@@ -129,15 +169,14 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
|
|
|
129
169
|
});
|
|
130
170
|
|
|
131
171
|
const testEntry = Effect.gen(function* () {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
if (args.length > 0 && tier === undefined) return yield* new Usage({ message: USAGE });
|
|
172
|
+
const options = testOptions(process.argv.slice(2));
|
|
173
|
+
if (options === undefined) return yield* new Usage({ message: USAGE });
|
|
135
174
|
const fs = yield* FileSystem.FileSystem;
|
|
136
|
-
const declarations = yield* readSkipDeclarations(process.cwd(), tier);
|
|
175
|
+
const declarations = yield* readSkipDeclarations(process.cwd(), options.tier);
|
|
137
176
|
const environment = yield* environmentOf;
|
|
138
177
|
|
|
139
178
|
const outfile = (yield* Path.Path).join(yield* fs.makeTempDirectoryScoped({ prefix: "checks-test-" }), "junit.xml");
|
|
140
|
-
const suitePassed = (yield* runSuite(outfile,
|
|
179
|
+
const suitePassed = (yield* runSuite(outfile, options)) === ChildProcessSpawner.ExitCode(0);
|
|
141
180
|
if (!(yield* fs.exists(outfile))) {
|
|
142
181
|
if (!suitePassed) {
|
|
143
182
|
yield* Console.error(`${NAME}: bun test failed before it wrote a report, so no skip was judged`);
|
|
@@ -146,9 +185,12 @@ const testEntry = Effect.gen(function* () {
|
|
|
146
185
|
return yield* new ReportError({ message: `bun test passed but wrote no report to ${outfile}` });
|
|
147
186
|
}
|
|
148
187
|
|
|
149
|
-
const
|
|
188
|
+
const { results, crashedFiles } = yield* parseReport(yield* fs.readFileString(outfile));
|
|
189
|
+
for (const file of crashedFiles) yield* Console.error(`${NAME}: the worker running ${file} crashed, so its skips were not judged`);
|
|
190
|
+
const judged = declarations.filter((declaration) => !crashedFiles.includes(declaration.file));
|
|
191
|
+
const verdict = judgeSkips(results, judged, environment);
|
|
150
192
|
yield* passes(verdict) ? Console.log(report(verdict)) : Console.error(report(verdict));
|
|
151
|
-
return suitePassed && passes(verdict);
|
|
193
|
+
return suitePassed && crashedFiles.length === 0 && passes(verdict);
|
|
152
194
|
}).pipe(Effect.scoped);
|
|
153
195
|
|
|
154
196
|
if (import.meta.main) runMain(NAME, testEntry);
|
package/stryker.preset.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readdirSync } from "node:fs";
|
|
2
2
|
import { availableParallelism } from "node:os";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { GUARD_IGNORER } from "./
|
|
5
|
-
import { fullRunRefusal } from "./
|
|
4
|
+
import { GUARD_IGNORER } from "./dist/mutation/mutation-guard-plugin.js";
|
|
5
|
+
import { fullRunRefusal } from "./dist/mutation/mutation-refusal.js";
|
|
6
6
|
|
|
7
7
|
const [, , command, ...args] = process.argv;
|
|
8
8
|
const refusal = command === "run" ? fullRunRefusal(args, process.env.CI ?? "") : undefined;
|
|
@@ -22,7 +22,7 @@ export default {
|
|
|
22
22
|
plugins: [
|
|
23
23
|
"@stryker-mutator/*",
|
|
24
24
|
"@hughescr/stryker-bun-runner",
|
|
25
|
-
fileURLToPath(new URL("./
|
|
25
|
+
fileURLToPath(new URL("./dist/mutation/mutation-guard-plugin.js", import.meta.url)),
|
|
26
26
|
],
|
|
27
27
|
ignorers: [GUARD_IGNORER],
|
|
28
28
|
testRunner: "bun",
|
|
@@ -1,13 +0,0 @@
|
|
|
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 }];
|