supercov 4.0.0 → 4.1.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/docs/cli.md CHANGED
@@ -147,6 +147,7 @@ npx supercov runs <run-id> [query] [options]
147
147
  | `decision <id \| path:line>` | Understand missing boolean outcomes and MC/DC witnesses |
148
148
  | `line <path:line>` | See one line's state, obligations, and covering tests |
149
149
  | `test <id \| name>` | See the coverage attributed to one test |
150
+ | `tests without-evidence` | List the tests that made assertions and recorded no coverage, which the summary counts in a warning |
150
151
  | `kinds` | Group coverage by test level, such as unit or E2E |
151
152
  | `runners` | Group coverage by test runner |
152
153
  | `scope` | See what is source and what is not, by directory and reason; `--files` lists every file |
@@ -175,12 +176,16 @@ npx supercov runs latest files --group dir --depth 2 --metric branches
175
176
  ```
176
177
 
177
178
  ```
178
- Directory Files Lines e2e unit All
179
- app 4 15 86.67% 0.00% 100.00%
180
- lib 1 10 90.00% 40.00% 90.00%
181
- components 1 2 100.00% 0.00% 100.00%
179
+ Directory Files Lines e2e unit no test All
180
+ app 5 23 86.96% 0.00% 8.70% 95.65%
181
+ lib 1 10 90.00% 40.00% 0.00% 90.00%
182
+ . 2 8 75.00% 0.00% 25.00% 100.00%
183
+ components 1 2 100.00% 0.00% 0.00% 100.00%
182
184
  ```
183
185
 
186
+ `no test` is what ran only while no test was running, such as a server
187
+ starting before the first test. The column is there when a directory has any.
188
+
184
189
  In `--json`, every file of `files` and `gaps` carries `totals` and `covered`
185
190
  for each metric beside what is missing, so a percentage can be worked out for
186
191
  any file or set of files.
@@ -106,6 +106,14 @@ By test kind
106
106
  no test lines 17.14% branches 0.00% MC/DC 0.00%
107
107
  ```
108
108
 
109
+ `files --group dir` has the same figure as a `no test` column wherever a
110
+ directory has any, so a directory's `All` never stands above its kinds
111
+ unexplained.
112
+
113
+ A test that made assertions and has no covered line to its name is counted in
114
+ a warning on the summary. `npx supercov runs latest tests without-evidence`
115
+ lists every such test, with its file, kind and outcome.
116
+
109
117
  See [Supported suites](supported-suites.md) for the attribution available from
110
118
  each runner.
111
119
 
@@ -39,10 +39,16 @@ and the instrumented copy is not what you wrote:
39
39
  - Biome, oxlint, dprint, cspell and knip run on your source as you wrote it:
40
40
  the copy's `node_modules/.bin` starts each of them in a view of the copy
41
41
  that holds every file in its original text. A file the tool writes there
42
- (a report, a cache) is the command's output like any other. A change it
43
- makes to a source file (`--write`, `--fix`) is not applied during a measured
44
- run, and the run says so. To have another tool that reads source as text run
45
- the same way, name it: `SUPERCOV_SOURCE_TOOLS=typos,stylelint`.
42
+ (a report, a cache) is the command's output like any other. To have another
43
+ tool that reads source as text run the same way, name it:
44
+ `SUPERCOV_SOURCE_TOOLS=typos,stylelint`.
45
+ - A change one of these tools makes to a source file (`prettier --write`,
46
+ `eslint --fix`, `biome check --write`, `dprint fmt`) is made in your
47
+ project when the command ends, and a tool later in the same command reads
48
+ the changed text. The tests ran the copy instrumented before the change, so
49
+ the run measured the file as it was, says so, and reads as stale; the next
50
+ run measures the changed file. A file you edited yourself while the command
51
+ ran keeps your edit, and the run names it.
46
52
  - Coverage tools the command runs itself (tap, c8, nyc, Jest's and Vitest's
47
53
  `--coverage`) still collect and report coverage. They measure the
48
54
  instrumented copy, and report close to what they report without Supercov,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Coverage, security and code quality for coding agents",
5
5
  "keywords": [
6
6
  "coverage",
@@ -119,14 +119,14 @@
119
119
  "test:windows-tls": "node scripts/windows-tls-test.mjs"
120
120
  },
121
121
  "optionalDependencies": {
122
- "@supercov/cli-darwin-arm64": "4.0.0",
123
- "@supercov/cli-darwin-x64": "4.0.0",
124
- "@supercov/cli-linux-arm64-gnu": "4.0.0",
125
- "@supercov/cli-linux-arm64-musl": "4.0.0",
126
- "@supercov/cli-linux-x64-gnu": "4.0.0",
127
- "@supercov/cli-linux-x64-musl": "4.0.0",
128
- "@supercov/cli-win32-arm64": "4.0.0",
129
- "@supercov/cli-win32-x64": "4.0.0"
122
+ "@supercov/cli-darwin-arm64": "4.1.0",
123
+ "@supercov/cli-darwin-x64": "4.1.0",
124
+ "@supercov/cli-linux-arm64-gnu": "4.1.0",
125
+ "@supercov/cli-linux-arm64-musl": "4.1.0",
126
+ "@supercov/cli-linux-x64-gnu": "4.1.0",
127
+ "@supercov/cli-linux-x64-musl": "4.1.0",
128
+ "@supercov/cli-win32-arm64": "4.1.0",
129
+ "@supercov/cli-win32-x64": "4.1.0"
130
130
  },
131
131
  "peerDependencies": {
132
132
  "@playwright/test": ">=1.55.0",
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  import Module, { register, syncBuiltinESMExports } from "node:module";
10
10
  import fs, { closeSync, openSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
11
- import { isAbsolute, relative, resolve, sep } from "node:path";
11
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { installLaunchSupervisor, wrapImportedCapability } from "./launchSupervisor.mjs";
14
14
  import { __supercovBindCapabilityWrapper } from "./capability.mjs";
@@ -85,6 +85,34 @@ if (process.env.SUPERCOV_DURABLE_EVIDENCE_EACH_TEST === "1" ||
85
85
  // and may evaluate before any ESM instrumented file imports it.
86
86
  globalThis.__supercovRuntime ??= globalThis.__SUPERCOV_DIRECT_RUNTIME__;
87
87
  }
88
+ // Next.js runs a middleware and a route with `runtime = "edge"` in a VM
89
+ // context of its own, the Edge Runtime's, and that context's global is not
90
+ // this one. An instrumented file found no runtime there: every request to an
91
+ // application with a middleware.ts failed on "Cannot read properties of
92
+ // undefined (reading 'mcdcBegin')", and `next build` failed collecting page
93
+ // data for an edge route. Such a context reads this process's runtime, so
94
+ // what runs there is measured like the rest of the server.
95
+ const virtualMachine = Module._load("node:vm", undefined, false);
96
+ const createContext = virtualMachine.createContext;
97
+ virtualMachine.createContext = function createContextWithRuntime(...parameters) {
98
+ const context = Reflect.apply(createContext, this, parameters);
99
+ if (parameters[1]?.name === "Edge Runtime") {
100
+ try {
101
+ Object.defineProperty(context, "__SUPERCOV_DIRECT_RUNTIME__", {
102
+ configurable: true,
103
+ enumerable: false,
104
+ get: () => globalThis.__SUPERCOV_DIRECT_RUNTIME__ ?? process.__SUPERCOV_DIRECT_RUNTIME__,
105
+ set(value) {
106
+ Object.defineProperty(context, "__SUPERCOV_DIRECT_RUNTIME__", {
107
+ configurable: true, enumerable: false, writable: true, value,
108
+ });
109
+ },
110
+ });
111
+ }
112
+ catch { }
113
+ }
114
+ return context;
115
+ };
88
116
  // Workers are independent Node processes and an explicit `execArgv: []`
89
117
  // otherwise strips the preload that supplies the isolated runtime. Preserve
90
118
  // every user option while adding exactly one Supercov import.
@@ -196,6 +224,32 @@ else if (/\/node_modules\/next\/dist\/compiled\/jest-worker\/processChild\.js$/.
196
224
  return Reflect.apply(compile, this, [content, filename, ...rest]);
197
225
  };
198
226
  }
227
+ // Next.js looks for lockfiles from its directory upwards, takes the outermost
228
+ // as its workspace root, and warns when it finds more than one. The copy
229
+ // holds the project's lockfile and lies inside the project, so every build
230
+ // under Supercov warned twice that the root "may not be correct", naming the
231
+ // copy's lockfile. The root Next picks is the project, which is right: it
232
+ // holds the copy and the dependencies. The copy's lockfile is left out of
233
+ // what the warning counts, and one the project really has twice still warns.
234
+ if (/\/node_modules\/(?:\.bin\/next$|next\/dist\/)/.test(entrypoint)) {
235
+ const copy = fileURLToPath(new URL("../../", import.meta.url));
236
+ const copies = [...new Set([copy, (() => {
237
+ try {
238
+ return realpathSync(copy);
239
+ }
240
+ catch {
241
+ return copy;
242
+ }
243
+ })()])];
244
+ const declared = "function warnDuplicatedLockFiles(lockFiles) {";
245
+ const compile = Module.prototype._compile;
246
+ Module.prototype._compile = function _compile(content, filename, ...rest) {
247
+ if (typeof content === "string" && /[\\/]next[\\/]dist[\\/]lib[\\/]find-root\.js$/.test(filename)) {
248
+ content = content.replace(declared, `${declared} lockFiles = lockFiles.filter((file, index) => index === lockFiles.length - 1 || !${JSON.stringify(copies)}.some((copy) => file.startsWith(copy)));`);
249
+ }
250
+ return Reflect.apply(compile, this, [content, filename, ...rest]);
251
+ };
252
+ }
199
253
  function installAuthoredSourceView() {
200
254
  let authored;
201
255
  try {
@@ -205,6 +259,8 @@ function installAuthoredSourceView() {
205
259
  authored = new Set();
206
260
  }
207
261
  const authoredRoot = fileURLToPath(new URL("./.authored/", import.meta.url));
262
+ // What a tool of the command wrote to a rewritten file: `--fix`, `--write`.
263
+ const changedRoot = fileURLToPath(new URL("./.changed/", import.meta.url));
208
264
  const workspace = fileURLToPath(new URL("../../", import.meta.url));
209
265
  const roots = [...new Set([workspace, (() => {
210
266
  try {
@@ -231,9 +287,28 @@ function installAuthoredSourceView() {
231
287
  }
232
288
  return undefined;
233
289
  };
290
+ const { existsSync: exists, mkdirSync: makeDirectory } = fs;
291
+ // A rewritten file reads as the command has it now: what one of its
292
+ // tools made of it, or what its author wrote.
234
293
  const authoredPath = (path) => {
235
294
  const local = inside(path);
236
- return local !== undefined && authored.has(local) ? resolve(authoredRoot, local) : path;
295
+ if (local === undefined || !authored.has(local))
296
+ return path;
297
+ const changed = resolve(changedRoot, local);
298
+ return exists(changed) ? changed : resolve(authoredRoot, local);
299
+ };
300
+ // And it is written beside the instrumented copy, never over it. Prettier
301
+ // with `--write` and ESLint with `--fix` replaced the copy with the
302
+ // source they had read: the file then ran unmeasured, and one whose tests
303
+ // passed read 0% covered. Supercov gives the project the change when the
304
+ // command ends.
305
+ const changedPath = (path) => {
306
+ const local = inside(path);
307
+ if (local === undefined || !authored.has(local))
308
+ return path;
309
+ const changed = resolve(changedRoot, local);
310
+ makeDirectory(dirname(changed), { recursive: true });
311
+ return changed;
237
312
  };
238
313
  // A recursive listing names nested entries by relative path, or as
239
314
  // entries whose parent is inside .supercov.
@@ -250,7 +325,17 @@ function installAuthoredSourceView() {
250
325
  ? entries
251
326
  : entries.filter((entry) => !hidden(entry));
252
327
  const { readFileSync: readSync, readFile: readCallback, readdirSync: listSync, readdir: listCallback } = fs;
253
- const { readFile: readPromise, readdir: listPromise } = fs.promises;
328
+ const { readFile: readPromise, readdir: listPromise, writeFile: writePromise } = fs.promises;
329
+ const { writeFileSync: writeSync, writeFile: writeCallback } = fs;
330
+ fs.writeFileSync = function writeFileSync(path, ...rest) {
331
+ return Reflect.apply(writeSync, this, [changedPath(path), ...rest]);
332
+ };
333
+ fs.writeFile = function writeFile(path, ...rest) {
334
+ return Reflect.apply(writeCallback, this, [changedPath(path), ...rest]);
335
+ };
336
+ fs.promises.writeFile = function writeFile(path, ...rest) {
337
+ return Reflect.apply(writePromise, this, [changedPath(path), ...rest]);
338
+ };
254
339
  fs.readFileSync = function readFileSync(path, ...rest) {
255
340
  return Reflect.apply(readSync, this, [authoredPath(path), ...rest]);
256
341
  };
@@ -886,21 +886,40 @@ function finishAssertionPhase(phase, error) {
886
886
  phase.error = error instanceof Error ? error.message : String(error);
887
887
  }
888
888
 
889
- function cleanInstrumentationStack(error) {
889
+ const INSTRUMENTATION_FRAME = /[\\/]\.supercov[\\/](?:node_modules[\\/])?(?:playwright|nodeTest|vitest|runtime|launchSupervisor|nodeAssert|nodeAssertStrict|nodeAssertAdapter|register|resolve-loader)\.(?:js|mjs)(?::|\))/u;
890
+ // A lexical wrapper runs the assertion in a function of its own, so its
891
+ // stack had two frames where the author wrote one: the function Supercov
892
+ // added, at the assertion, and below the runtime's frames the function the
893
+ // author wrote, at the wrapper. Vitest printed both, `:12:35` and `:12:1`.
894
+ // They are one frame again: the author's function, where the assertion is.
895
+ function cleanInstrumentationStack(error, lexical = false) {
890
896
  if (!error || typeof error !== "object" || typeof error.stack !== "string")
891
897
  return error;
892
898
  const lines = error.stack.split("\n");
893
- const visible = lines.filter((line, index) => index === 0 || !/[\\/]\.supercov[\\/](?:node_modules[\\/])?(?:playwright|nodeTest|vitest|runtime|launchSupervisor|nodeAssert|nodeAssertStrict|nodeAssertAdapter|register|resolve-loader)\.(?:js|mjs)(?::|\))/u.test(line));
894
- if (visible.length !== lines.length) {
895
- try {
896
- error.stack = visible.join("\n");
897
- } catch (e) {
898
- }
899
+ const hidden = lines.map((line, index) => index > 0 && INSTRUMENTATION_FRAME.test(line));
900
+ const first = hidden.indexOf(true);
901
+ if (first < 0)
902
+ return error;
903
+ const last = hidden.lastIndexOf(true);
904
+ let visible;
905
+ const frame = /^(\s*at (?:async )?)(?:(.*?) \()?(.*?:\d+:\d+)\)?$/;
906
+ const added = lexical && first > 1 ? frame.exec(lines[first - 1]) : null;
907
+ const written = added && last + 1 < lines.length ? frame.exec(lines[last + 1]) : null;
908
+ if (added && written && added[2] === void 0 && !/^node:/.test(written[3])) {
909
+ const merged = written[2] === void 0 ? lines[first - 1] : `${written[1]}${written[2]} (${added[3]})`;
910
+ visible = [...lines.slice(0, first - 1), merged, ...lines.slice(last + 2)];
911
+ } else {
912
+ visible = lines.filter((line, index) => !hidden[index]);
913
+ }
914
+ try {
915
+ error.stack = visible.join("\n");
916
+ } catch (e) {
899
917
  }
900
918
  return error;
901
919
  }
902
920
  function withNodeAssertionPhase(operation, source, callback) {
903
921
  var _a8;
922
+ const lexical = typeof source === "string";
904
923
  const context = currentRequestContext();
905
924
  const scope = context.scope;
906
925
  if (!scope)
@@ -944,13 +963,13 @@ function withNodeAssertionPhase(operation, source, callback) {
944
963
  return value;
945
964
  }, (error) => {
946
965
  finishAssertionPhase(phase, error);
947
- throw cleanInstrumentationStack(error);
966
+ throw cleanInstrumentationStack(error, lexical);
948
967
  });
949
968
  finishAssertionPhase(phase);
950
969
  return result;
951
970
  } catch (error) {
952
971
  finishAssertionPhase(phase, error);
953
- throw cleanInstrumentationStack(error);
972
+ throw cleanInstrumentationStack(error, lexical);
954
973
  }
955
974
  }
956
975
  // Callee binding preserves receiver, getter/evaluation order, spreads and the
@@ -1,7 +1,112 @@
1
- import { relative, resolve, sep } from "node:path";
1
+ import fs from "node:fs";
2
+ import { syncBuiltinESMExports } from "node:module";
3
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
2
4
  import { fileURLToPath } from "node:url";
3
5
  import { inferTestProvenance } from "./provenance.mjs";
4
6
  import { appendEvidenceRecord } from "./atomic.mjs";
7
+ // The files of the copy Supercov rewrote, by path inside it, and where each
8
+ // is kept as its author wrote it or as a tool of the command changed it.
9
+ const copyRoots = (() => {
10
+ const copy = fileURLToPath(new URL("../../", import.meta.url));
11
+ try {
12
+ return [...new Set([copy, fs.realpathSync(copy)])];
13
+ }
14
+ catch {
15
+ return [copy];
16
+ }
17
+ })();
18
+ let rewritten;
19
+ function rewrittenFile(path) {
20
+ if (rewritten === undefined) {
21
+ try {
22
+ rewritten = new Set(JSON.parse(fs.readFileSync(new URL("./authored-sources.json", import.meta.url), "utf8")));
23
+ }
24
+ catch {
25
+ rewritten = new Set();
26
+ }
27
+ }
28
+ if (typeof path !== "string" || !isAbsolute(path))
29
+ return undefined;
30
+ for (const root of copyRoots) {
31
+ const local = relative(root, path);
32
+ if (local !== ".." && !local.startsWith(`..${sep}`) && !isAbsolute(local)) {
33
+ const file = local.split(sep).join("/");
34
+ return rewritten.has(file) ? file : undefined;
35
+ }
36
+ }
37
+ return undefined;
38
+ }
39
+ const SOURCE_MAP = "\n//# sourceMappingURL=data:application/json;base64,";
40
+ /**
41
+ * A rewritten file's source map names the project's own file, by absolute
42
+ * path, so a Node stack trace reads as the project's. Vitest prints a frame
43
+ * relative to its root, which is the copy: `../../../../lib/crypto.ts:3:11`
44
+ * where it prints `lib/crypto.ts:3:11` without Supercov, and a file outside
45
+ * its module graph gets no code under the frame. Vite is handed the same
46
+ * code with a map that names the file itself.
47
+ */
48
+ export function supercovSourceMaps() {
49
+ const { readFile } = fs.promises;
50
+ return {
51
+ name: "supercov:source-maps",
52
+ enforce: "pre",
53
+ async load(id) {
54
+ if (rewrittenFile(id) === undefined)
55
+ return null;
56
+ try {
57
+ const code = await readFile(id, "utf8");
58
+ const found = code.lastIndexOf(SOURCE_MAP);
59
+ if (found < 0)
60
+ return null;
61
+ const encoded = code.slice(found + SOURCE_MAP.length).trim();
62
+ if (/\s/.test(encoded))
63
+ return null;
64
+ const map = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
65
+ map.sources = [basename(id)];
66
+ return { code: code.slice(0, found + 1), map };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ },
72
+ };
73
+ }
74
+ // Vitest reads a file a failure names: to print the code around the nearest
75
+ // frame, and (Vitest 4) to look for a source map in it. In the copy that is
76
+ // the rewritten file. An assertion wrapped into one long line printed no code
77
+ // at all, any other line printed Supercov's, and positions that were already
78
+ // the author's were mapped a second time through the file's own map: a test
79
+ // file's frame at `:12:35` read `:12:0`. What Vitest itself reads is the text
80
+ // the file was rewritten from. What runs is not read here: Vite loads it.
81
+ let readsAuthored = false;
82
+ function readAuthoredFromVitest() {
83
+ if (readsAuthored)
84
+ return;
85
+ readsAuthored = true;
86
+ const { readFileSync, existsSync } = fs;
87
+ const kept = (name, file) => fileURLToPath(new URL(`./${name}/${file}`, import.meta.url));
88
+ const calledByVitest = () => {
89
+ const holder = {};
90
+ const limit = Error.stackTraceLimit;
91
+ try {
92
+ Error.stackTraceLimit = 1;
93
+ Error.captureStackTrace(holder, supercovReadFileSync);
94
+ }
95
+ finally {
96
+ Error.stackTraceLimit = limit;
97
+ }
98
+ return /[\\/]vitest[\\/]dist[\\/]/.test(String(holder.stack).split("\n")[1] ?? "");
99
+ };
100
+ function supercovReadFileSync(path, ...rest) {
101
+ const file = rewrittenFile(path);
102
+ if (file === undefined || !calledByVitest())
103
+ return Reflect.apply(readFileSync, this, [path, ...rest]);
104
+ const changed = kept(".changed", file);
105
+ return Reflect.apply(readFileSync, this, [existsSync(changed) ? changed : kept(".authored", file), ...rest]);
106
+ }
107
+ fs.readFileSync = supercovReadFileSync;
108
+ syncBuiltinESMExports();
109
+ }
5
110
  function sourcePath(moduleId) {
6
111
  const absolute = moduleId.startsWith("file:")
7
112
  ? fileURLToPath(moduleId)
@@ -29,6 +134,7 @@ export default class SupercovVitestReporter {
29
134
  }
30
135
  onInit(vitest) {
31
136
  this.configureProjects?.(vitest.projects);
137
+ readAuthoredFromVitest();
32
138
  }
33
139
  onBrowserInit(project) {
34
140
  this.configureProjects?.([project]);