module-spec 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.2.1
4
4
 
5
+ - Under vitest, a live mutant of a module-level constant or regex runs again in a fresh vitest
6
+ process. Stryker's vitest runner, given the module's test files, ran its tests without the
7
+ mutant switched on, so it read as live whatever the tests checked.
8
+
9
+ ## 0.2.0
10
+
11
+ - `mutate` counts only the module's core, shell and door tests. An adapter's test and a
12
+ contract test prove the adapter against the outside and are slow, so a mutant that only
13
+ they catch now lives until a test through the door catches it. On factory's chat module
14
+ those tests were 39 of 40 seconds of test time, rerun for every core mutant.
15
+ - Under vitest, `mutate` keeps each module's verdicts in
16
+ `node_modules/.cache/module-spec/mutate/`, and a rerun tests again only the mutants whose
17
+ code or covering tests changed.
5
18
  - `mutate` changes only `core/`, `shell/` and the door, as the spec says. Before,
6
19
  it also changed files in no part, such as an app's `main.ts` or a
7
20
  `vitest.config.ts`.
@@ -11,10 +11,18 @@ const OUTPUT_LIMIT = 512 * 1024 * 1024;
11
11
  const CAUGHT = new Set(["Killed", "Timeout"]);
12
12
  const LIVE = new Set(["Survived", "NoCoverage"]);
13
13
 
14
+ /** The project's own vitest, as Stryker's vitest runner loads it, or else the kit's. */
15
+ function vitestBin(root) {
16
+ const local = path.join(root, "node_modules", "vitest");
17
+ const dir = fs.existsSync(path.join(local, "package.json")) ? local : kitPackageDir("vitest");
18
+ return path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.vitest);
19
+ }
20
+
14
21
  /** The shell command that runs only `tests`, with the project's own runner. Stryker runs it once per mutant. */
15
22
  function testCommand(root, tests) {
16
23
  const files = tests.map((file) => JSON.stringify(file)).join(" ");
17
- return testRunnerOf(root) === "vitest" ? `node_modules/.bin/vitest run ${files}` : `${JSON.stringify(process.execPath)} --test ${files}`;
24
+ if (testRunnerOf(root) !== "vitest") return `${JSON.stringify(process.execPath)} --test ${files}`;
25
+ return `${JSON.stringify(process.execPath)} ${JSON.stringify(vitestBin(root))} run ${files}`;
18
26
  }
19
27
 
20
28
  const oneLine = (text) => {
@@ -40,37 +48,70 @@ function runnerConfig(root, tests) {
40
48
  };
41
49
  }
42
50
 
51
+ /**
52
+ * Under vitest, Stryker keeps each mutant's verdict and the tests that covered it, so a rerun tests again only the
53
+ * mutants whose code or covering tests changed. The command runner reports no tests, so a weakened test would keep
54
+ * its old verdict there, and it reruns everything. The file sits under node_modules/.cache, which no project commits.
55
+ */
56
+ function incrementalConfig(root, module) {
57
+ if (testRunnerOf(root) !== "vitest") return {};
58
+ const file = `${module.dir.replaceAll("/", "_")}.json`;
59
+ return { incremental: true, incrementalFile: path.join(root, "node_modules", ".cache", "module-spec", "mutate", file) };
60
+ }
61
+
62
+ /** One Stryker run with `config`, in its own folder under `work`, and the mutants it reported. */
63
+ function strykerRun(root, work, name, config) {
64
+ const report = path.join(work, `${name}.json`);
65
+ const configFile = path.join(work, `${name}.config.json`);
66
+ fs.writeFileSync(configFile, JSON.stringify({
67
+ ...config,
68
+ reporters: ["json"],
69
+ jsonReporter: { fileName: report },
70
+ tempDirName: path.join(work, `${name}-sandbox`),
71
+ cleanTempDir: "always",
72
+ checkers: [],
73
+ // Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
74
+ // runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
75
+ tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
76
+ logLevel: "error",
77
+ }));
78
+ const dir = kitPackageDir("@stryker-mutator/core");
79
+ const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
80
+ const run = spawnSync(process.execPath, [bin, "run", configFile], { cwd: root, env: environmentForTests(), encoding: "utf8", maxBuffer: OUTPUT_LIMIT });
81
+ if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
82
+ return Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) => mutants.map((mutant) => ({ ...mutant, file })));
83
+ }
84
+
85
+ const mutantKey = (mutant) => `${mutant.file}:${mutant.location.start.line}:${mutant.location.start.column} ${mutant.mutatorName} ${mutant.replacement ?? ""}`;
86
+ /** Stryker reads a range's columns from another origin than its report writes them, so a range names whole lines. */
87
+ const linesOf = ({ file, location: { start, end } }) => `${file}:${start.line}-${end.line}`;
88
+
89
+ /**
90
+ * Stryker's vitest runner, given the module's test files, runs a static mutant's tests without the mutant switched
91
+ * on, so a module-level constant or regex reads as live whatever its tests check. A command run starts vitest anew
92
+ * for each mutant, so the module loads with the mutant on. Only the live static mutants run again that way.
93
+ */
94
+ function withStaticMutantsRetested(root, work, tests, mutants) {
95
+ const doubtful = mutants.filter((mutant) => mutant.static && LIVE.has(mutant.status));
96
+ if (doubtful.length === 0) return mutants;
97
+ const retested = new Map(
98
+ strykerRun(root, work, "static", {
99
+ testRunner: "command",
100
+ commandRunner: { command: testCommand(root, tests) },
101
+ coverageAnalysis: "off",
102
+ plugins: [],
103
+ mutate: [...new Set(doubtful.map(linesOf))],
104
+ }).map((mutant) => [mutantKey(mutant), mutant.status]),
105
+ );
106
+ return mutants.map((mutant) => (retested.has(mutantKey(mutant)) ? { ...mutant, status: retested.get(mutantKey(mutant)) } : mutant));
107
+ }
108
+
43
109
  /** Mutation testing by the kit's own StrykerJS, in a sandbox under the system temp folder. */
44
- export async function mutate({ root, files, tests }) {
110
+ export async function mutate({ root, module, files, tests }) {
45
111
  const work = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-stryker-"));
46
112
  try {
47
- const report = path.join(work, "report.json");
48
- const configFile = path.join(work, "stryker.config.json");
49
- fs.writeFileSync(configFile, JSON.stringify({
50
- ...runnerConfig(root, tests),
51
- mutate: files,
52
- reporters: ["json"],
53
- jsonReporter: { fileName: report },
54
- tempDirName: path.join(work, "sandbox"),
55
- cleanTempDir: "always",
56
- checkers: [],
57
- // Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
58
- // runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
59
- tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
60
- logLevel: "error",
61
- }));
62
- const dir = kitPackageDir("@stryker-mutator/core");
63
- const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
64
- const run = spawnSync(process.execPath, [bin, "run", configFile], {
65
- cwd: root,
66
- env: environmentForTests(),
67
- encoding: "utf8",
68
- maxBuffer: OUTPUT_LIMIT,
69
- });
70
- if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
71
- const mutants = Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) =>
72
- mutants.map((mutant) => ({ ...mutant, file })),
73
- );
113
+ const first = strykerRun(root, work, "run", { ...runnerConfig(root, tests), mutate: files, ...incrementalConfig(root, module) });
114
+ const mutants = testRunnerOf(root) === "vitest" ? withStaticMutantsRetested(root, work, tests, first) : first;
74
115
  return {
75
116
  killed: mutants.filter((mutant) => CAUGHT.has(mutant.status)).length,
76
117
  survivors: mutants
package/lib/mutation.js CHANGED
@@ -15,13 +15,19 @@ export const MUTATION_REPORT = "mutation-report.txt";
15
15
  */
16
16
  const MUTATED = new Set(["core", "shell"]);
17
17
 
18
- /** The files mutation testing changes in a module, and the module's own tests, which must notice. */
18
+ /**
19
+ * The files mutation testing changes in a module, and the module's own tests, which must notice. An adapter's test
20
+ * proves the adapter against the outside and is slow, while the code it reaches is proven through the door, so it
21
+ * does not count.
22
+ */
19
23
  export function mutationTargets(module) {
20
24
  const files = [];
21
25
  const tests = [];
22
26
  for (const file of module.files) {
23
27
  const place = placeOf(module, file);
24
- if (place.test) tests.push(file);
28
+ if (place.test) {
29
+ if (place.part !== "adapters" && !place.contractTest) tests.push(file);
30
+ }
25
31
  else if (place.door || MUTATED.has(place.part)) files.push(file);
26
32
  }
27
33
  return { files, tests };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "module-spec",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "How every project splits its code into modules, with the checks that enforce it. The why behind each rule: spec/.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@12.10.1",
package/spec/tests.md CHANGED
@@ -130,8 +130,10 @@ code does. Mutation testing catches it, because every mutant lives.
130
130
  hold only types, not `adapters/`, whose contract tests need the real
131
131
  service, and not `view/`, which holds no rules
132
132
  ([F3](frontend.md#f3-a-view-holds-no-rules)).
133
- - **Which tests count:** only the module's own. A mutant that only another
134
- module's test catches still lives.
133
+ - **Which tests count:** only the module's own, and not its adapters' tests
134
+ or contract tests. Those prove an adapter against the outside and are slow.
135
+ The core they reach is proven through the door. A mutant that only another
136
+ module's test, or an adapter's test, catches still lives.
135
137
  - **Zero live mutants:** `module-spec mutate` fails on any live mutant, and
136
138
  names it, even on a module's first run.
137
139
  - **When no test can tell:** some mutants behave exactly like the code, such