@avi2dg/checks 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -2
  3. package/{commitlint.config.js → commitlint.config.ts} +1 -1
  4. package/dist/mutation/mutation-guard-plugin.js +40 -0
  5. package/dist/mutation/mutation-refusal.js +23 -0
  6. package/docs/configs/commit-messages.md +2 -2
  7. package/docs/design.md +20 -0
  8. package/docs/gates/checks-ci-wiring.md +3 -2
  9. package/docs/gates/checks-copy.md +113 -0
  10. package/docs/gates/checks-docs-only.md +3 -2
  11. package/docs/gates/checks-docs.md +3 -0
  12. package/docs/gates/checks-flake.md +49 -1
  13. package/docs/gates/checks-lint.md +2 -1
  14. package/docs/gates/checks-mutation-baseline.md +4 -2
  15. package/docs/gates/checks-mutation-compare.md +33 -8
  16. package/docs/gates/checks-mutation-scope.md +140 -0
  17. package/docs/gates/checks-mutation.md +11 -7
  18. package/docs/gates/checks-no-plain-js.md +60 -0
  19. package/docs/gates/checks-test-layout.md +3 -2
  20. package/docs/gates/checks-test.md +35 -6
  21. package/package.json +18 -6
  22. package/src/core/declared-inputs.ts +76 -0
  23. package/src/core/gates.ts +4 -0
  24. package/src/core/git.ts +9 -0
  25. package/src/delivery/ci-wiring.ts +25 -11
  26. package/src/delivery/commit-title.ts +2 -2
  27. package/src/docs/copy-html.ts +110 -0
  28. package/src/docs/copy-text.ts +70 -0
  29. package/src/docs/copy.ts +70 -0
  30. package/src/docs/prose-matchers.ts +14 -14
  31. package/src/quality/frontend-syntax.ts +9 -52
  32. package/src/quality/no-plain-js.ts +71 -0
  33. package/src/testing/flake-preload.ts +4 -0
  34. package/src/testing/flake.ts +142 -16
  35. package/src/testing/{mutation-scope.js → mutation-refusal.ts} +11 -9
  36. package/src/testing/mutation-scope.ts +354 -0
  37. package/src/testing/mutation.ts +1 -1
  38. package/src/testing/test-layout.ts +8 -15
  39. package/src/testing/test-report.ts +20 -5
  40. package/src/testing/test-skips.ts +1 -1
  41. package/src/testing/test.ts +53 -11
  42. package/stryker.preset.js +3 -3
  43. package/src/testing/mutation-guard-plugin.js +0 -13
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { git } from "../core/git.ts";
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 TEST_SCRIPTS: readonly string[] = [TEST_ENTRY_POINT.bin, `bun src/${TEST_ENTRY_POINT.script}`];
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 === expected) return undefined;
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
- if (typeof test !== "string" || !TEST_SCRIPTS.includes(test)) {
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 exactly "${TEST_ENTRY_POINT.bin}", which runs bun test --randomize and judges its skips, found ${JSON.stringify(test ?? null)}`,
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], results);
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;
@@ -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} takes no arguments except --tier=live or --tier=pixel`;
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
- const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTier | undefined) {
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 tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
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 args = process.argv.slice(2);
133
- const tier = args.length === 0 ? undefined : args.length === 1 ? TIERS.get(args[0] ?? "") : undefined;
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, tier)) === ChildProcessSpawner.ExitCode(0);
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 verdict = judgeSkips(yield* parseReport(yield* fs.readFileString(outfile)), declarations, environment);
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 "./src/testing/mutation-guard-plugin.js";
5
- import { fullRunRefusal } from "./src/testing/mutation-scope.js";
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("./src/testing/mutation-guard-plugin.js", import.meta.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 }];