module-spec 0.2.0 → 0.2.2

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,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - `mutate` names every live mutant. When two shared a file and a change, such as two
6
+ `StringLiteral: ""`, it named only the first, so a module showed 20 of its 70.
7
+
8
+ ## 0.2.1
9
+
10
+ - Under vitest, a live mutant of a module-level constant or regex runs again in a fresh vitest
11
+ process. Stryker's vitest runner, given the module's test files, ran its tests without the
12
+ mutant switched on, so it read as live whatever the tests checked.
13
+
3
14
  ## 0.2.0
4
15
 
5
16
  - `mutate` counts only the module's core, shell and door tests. An adapter's test and a
@@ -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) => {
@@ -51,38 +59,59 @@ function incrementalConfig(root, module) {
51
59
  return { incremental: true, incrementalFile: path.join(root, "node_modules", ".cache", "module-spec", "mutate", file) };
52
60
  }
53
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
+
54
109
  /** Mutation testing by the kit's own StrykerJS, in a sandbox under the system temp folder. */
55
110
  export async function mutate({ root, module, files, tests }) {
56
111
  const work = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-stryker-"));
57
112
  try {
58
- const report = path.join(work, "report.json");
59
- const configFile = path.join(work, "stryker.config.json");
60
- fs.writeFileSync(configFile, JSON.stringify({
61
- ...runnerConfig(root, tests),
62
- mutate: files,
63
- reporters: ["json"],
64
- jsonReporter: { fileName: report },
65
- tempDirName: path.join(work, "sandbox"),
66
- cleanTempDir: "always",
67
- checkers: [],
68
- // Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
69
- // runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
70
- tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
71
- logLevel: "error",
72
- ...incrementalConfig(root, module),
73
- }));
74
- const dir = kitPackageDir("@stryker-mutator/core");
75
- const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
76
- const run = spawnSync(process.execPath, [bin, "run", configFile], {
77
- cwd: root,
78
- env: environmentForTests(),
79
- encoding: "utf8",
80
- maxBuffer: OUTPUT_LIMIT,
81
- });
82
- if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
83
- const mutants = Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) =>
84
- mutants.map((mutant) => ({ ...mutant, file })),
85
- );
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;
86
115
  return {
87
116
  killed: mutants.filter((mutant) => CAUGHT.has(mutant.status)).length,
88
117
  survivors: mutants
package/lib/mutation.js CHANGED
@@ -68,8 +68,9 @@ export function judge(accepted, survivors, accept) {
68
68
  for (const entry of accepted) left.set(keyOf(entry), (left.get(keyOf(entry)) ?? 0) + 1);
69
69
  const fresh = survivors.filter((survivor) => {
70
70
  const count = left.get(keyOf(survivor)) ?? 0;
71
+ if (count === 0) return true;
71
72
  left.set(keyOf(survivor), count - 1);
72
- return count === 0;
73
+ return false;
73
74
  });
74
75
  return { failed: fresh.length > 0, fresh };
75
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "module-spec",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",