supercov 4.11.0 → 4.12.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
@@ -349,6 +349,19 @@ same run:
349
349
 
350
350
  ## Export for other tools
351
351
 
352
+ Every obligation a run left uncovered, one JSON object per line:
353
+
354
+ ```sh supercov-example
355
+ npx supercov runs latest gaps --format jsonl --metric mcdc --output gaps.jsonl
356
+ npx supercov runs latest gaps --format jsonl > gaps.jsonl
357
+ ```
358
+
359
+ Each line holds the file, the line, the source of that line and what `runs
360
+ <id> file` says of the obligation: for an MC/DC condition its decision, the
361
+ condition and the vectors observed. `--metric`, `--filter`, `--kind` and
362
+ `--runner` narrow it as they narrow `gaps`, and nothing is paged, so a tool or
363
+ an agent reads a run's every gap in one pass instead of a page at a time.
364
+
352
365
  ```sh supercov-example
353
366
  npx supercov runs report --format lcov --output coverage/lcov.info
354
367
  npx supercov runs report --format cobertura --output coverage/cobertura.xml
@@ -468,6 +481,20 @@ The same filters can focus a comparison:
468
481
  npx supercov diff <older-run> <newer-run> --kind e2e
469
482
  ```
470
483
 
484
+ To see what a new test covered that the suite did not, measure it alone and
485
+ compare it with the suite's run, keeping only what it gained:
486
+
487
+ ```sh supercov-example
488
+ npx supercov -- <the new test alone>
489
+ npx supercov diff <suite-run> latest --gained
490
+ ```
491
+
492
+ A test run alone covers far less than the suite, so a plain `diff` of the two
493
+ lists nearly everything as lost. `--gained` lists only the lines, branches and
494
+ MC/DC conditions the newer run covers and the older does not, which is how to
495
+ check that a test reached the conditions it was written for without measuring
496
+ the whole suite. In `--json`, `gainedOnly` is `true` and `lost` is empty.
497
+
471
498
  ## Generate a portable HTML report
472
499
 
473
500
  ```sh
@@ -533,6 +560,16 @@ A source file
533
560
  added since the run is outside every test's record; the working-tree check
534
561
  says so, and the suite should run in full.
535
562
 
563
+ A server the suite starts is recorded too: a mock server `webServer` starts,
564
+ or the application's own. Each Node process of a Playwright run notes the
565
+ project files it loads and reads, and the tests it serves, by the test a
566
+ request names or, for one that names none (a webhook), the test running
567
+ then. A change to a file such a process loaded or read, a mock's code, a
568
+ helper it imports or the data it reads, affects the tests it served and no
569
+ others. A mock keeps its state between tests, so a mock's files count whole,
570
+ not route by route; a file the application's server loaded, a preload of its
571
+ own, affects every test it served.
572
+
536
573
  ### Which affected tests check the change
537
574
 
538
575
  When the run has an [assertion assessment](assertions.md), `tests affected`
@@ -655,6 +692,19 @@ A run left out is named with the files and the functions that changed. The
655
692
  merged run lists what differed and why it does not reach the earlier runs:
656
693
  `merged across:` in `runs <id>`, and `mergedAcross` in JSON.
657
694
 
695
+ A changed file that a server the run's tests reached loaded or read (see
696
+ [Find the tests a change affects](#find-the-tests-a-change-affects)) takes
697
+ only those tests from the run: measure them again, and the merge keeps the
698
+ rest of the run and leaves their old records out:
699
+
700
+ ```sh supercov-example
701
+ npx supercov runs <suite-run> tests affected --names # after changing a mock
702
+ npx supercov -- npx playwright test <those tests>
703
+ npx supercov merge
704
+ ```
705
+
706
+ Until they are measured again, the merge refuses and names them.
707
+
658
708
  When you know a change Supercov counts does not alter what the earlier tests
659
709
  ran, say so:
660
710
 
@@ -666,6 +716,22 @@ The product code, the dependencies, and what is measured must still be the
666
716
  same, and a run whose own test files changed is still left out. What was
667
717
  accepted is listed in `merged across:` as accepted.
668
718
 
719
+ Runs measured on different machines merge the same way. Each machine
720
+ measures its part of the suite on the same commit and toolchain, and one job
721
+ merges them after copying each run's directory from `.supercov/runs/` into its
722
+ own:
723
+
724
+ ```yaml
725
+ # Each of two jobs:
726
+ - run: npx supercov -- npx playwright test --shard=${{ matrix.shard }}/2
727
+ - uses: actions/upload-artifact@v4
728
+ with: { name: "supercov-${{ matrix.shard }}", path: .supercov/runs, include-hidden-files: true }
729
+ # Then one job:
730
+ - uses: actions/download-artifact@v4
731
+ with: { pattern: "supercov-*", path: .supercov/runs, merge-multiple: true }
732
+ - run: npx supercov merge
733
+ ```
734
+
669
735
  `assertions assess` assesses a merged run across every suite merged into it,
670
736
  reusing the answers saved when its parts were assessed. `tests affected` of a
671
737
  merged run is answered from the runs it was merged from, which hold the record
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supercov",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "description": "Coverage, security and code quality for coding agents",
5
5
  "keywords": [
6
6
  "coverage",
@@ -83,7 +83,7 @@
83
83
  "eval:skill": "claude plugin eval plugins/supercov --scaffold --trust-plugin --no-publish --model claude-sonnet-5 --judge-model claude-haiku-4-5 --threshold 0.66 --tag behavior trigger --allow-tools Bash Edit Write --max-cost-usd 15",
84
84
  "eval:skill-triggers": "claude plugin eval plugins/supercov --scaffold --trust-plugin --no-publish --model claude-sonnet-5 --judge-model claude-haiku-4-5 --threshold 0.66 --tag trigger --ablation none --max-cost-usd 5",
85
85
  "test:engine": "cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace && npm run test:runtime && npm run test:rust-assets && npm run test:engine-integrations",
86
- "test:engine-integrations": "cargo build -p supercov && node scripts/html-report-integration.mjs && node scripts/rust-process-supervision.mjs && node scripts/rust-direct-node-integration.mjs && node scripts/rust-public-run-integration.mjs && node scripts/rust-embedded-runtime-integration.mjs && node scripts/rust-direct-vitest-integration.mjs && node scripts/rust-direct-jest-integration.mjs && node scripts/mocha-failure-output.mjs && node scripts/failure-output.mjs node ava tap && node scripts/side-by-side.mjs node && node scripts/merge-across.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-custom-browser-playwright-integration.mjs && node scripts/cross-origin-requests.mjs && node scripts/leaving-page.mjs && node scripts/background-by-time.mjs && node scripts/python-server-playwright.mjs && node scripts/ruby-server-playwright.mjs && node scripts/go-server-playwright.mjs && node scripts/jvm-server-playwright.mjs && node scripts/docker-server-playwright.mjs && node scripts/rust-classic-browser-integration.mjs && node scripts/rust-top-packages-integration.mjs && node scripts/rust-generic-esbuild-integration.mjs && node scripts/rust-generic-tsc-integration.mjs && node scripts/rust-generic-build-matrix.mjs && node scripts/rust-vite-playwright-integration.mjs",
86
+ "test:engine-integrations": "cargo build -p supercov && node scripts/html-report-integration.mjs && node scripts/rust-process-supervision.mjs && node scripts/rust-direct-node-integration.mjs && node scripts/rust-public-run-integration.mjs && node scripts/rust-embedded-runtime-integration.mjs && node scripts/rust-direct-vitest-integration.mjs && node scripts/rust-direct-jest-integration.mjs && node scripts/mocha-failure-output.mjs && node scripts/failure-output.mjs node ava tap && node scripts/side-by-side.mjs node && node scripts/merge-across.mjs && node scripts/mock-reach.mjs && node scripts/rust-direct-playwright-integration.mjs && node scripts/rust-custom-browser-playwright-integration.mjs && node scripts/cross-origin-requests.mjs && node scripts/leaving-page.mjs && node scripts/background-by-time.mjs && node scripts/python-server-playwright.mjs && node scripts/ruby-server-playwright.mjs && node scripts/go-server-playwright.mjs && node scripts/jvm-server-playwright.mjs && node scripts/docker-server-playwright.mjs && node scripts/rust-classic-browser-integration.mjs && node scripts/rust-top-packages-integration.mjs && node scripts/rust-generic-esbuild-integration.mjs && node scripts/rust-generic-tsc-integration.mjs && node scripts/rust-generic-build-matrix.mjs && node scripts/rust-vite-playwright-integration.mjs",
87
87
  "test:platform": "cargo test --workspace && cargo build -p supercov && node scripts/rust-process-supervision.mjs && node scripts/workspace-crash-integration.mjs",
88
88
  "test:native-package": "cargo build --release -p supercov && node scripts/native-package-integration.mjs && node scripts/native-release-set-integration.mjs",
89
89
  "test:pypi-wheel": "node scripts/pypi-wheel-integration.mjs",
@@ -120,14 +120,14 @@
120
120
  "test:windows-tls": "node scripts/windows-tls-test.mjs"
121
121
  },
122
122
  "optionalDependencies": {
123
- "@supercov/cli-darwin-arm64": "4.11.0",
124
- "@supercov/cli-darwin-x64": "4.11.0",
125
- "@supercov/cli-linux-arm64-gnu": "4.11.0",
126
- "@supercov/cli-linux-arm64-musl": "4.11.0",
127
- "@supercov/cli-linux-x64-gnu": "4.11.0",
128
- "@supercov/cli-linux-x64-musl": "4.11.0",
129
- "@supercov/cli-win32-arm64": "4.11.0",
130
- "@supercov/cli-win32-x64": "4.11.0"
123
+ "@supercov/cli-darwin-arm64": "4.12.0",
124
+ "@supercov/cli-darwin-x64": "4.12.0",
125
+ "@supercov/cli-linux-arm64-gnu": "4.12.0",
126
+ "@supercov/cli-linux-arm64-musl": "4.12.0",
127
+ "@supercov/cli-linux-x64-gnu": "4.12.0",
128
+ "@supercov/cli-linux-x64-musl": "4.12.0",
129
+ "@supercov/cli-win32-arm64": "4.12.0",
130
+ "@supercov/cli-win32-x64": "4.12.0"
131
131
  },
132
132
  "peerDependencies": {
133
133
  "@playwright/test": ">=1.55.0",
@@ -719,6 +719,16 @@ class CoveragePhaseController {
719
719
  ArrayBuffer.isView(result))
720
720
  return result;
721
721
  const candidate = result;
722
+ // A native dialog is Playwright's own object, returned as it is. Its
723
+ // `type()` was taken for a locator's action: it returned a promise
724
+ // where Playwright returns a string, and started a phase whose
725
+ // activation waits on the page, which runs nothing while the dialog
726
+ // is open, so the dialog could never be answered and the page never
727
+ // closed. Nothing a dialog's methods do is the application's work.
728
+ if (typeof candidate["accept"] === "function" &&
729
+ typeof candidate["dismiss"] === "function" &&
730
+ typeof candidate["defaultValue"] === "function")
731
+ return result;
722
732
  if (typeof candidate["contexts"] === "function" &&
723
733
  typeof candidate["newContext"] === "function")
724
734
  trackBrowser(result);
@@ -847,8 +847,64 @@ function skipCoverageThresholds(tool) {
847
847
  // instead, where such a file is read as an ES module and the hook never
848
848
  // sees it; the loader is told so it can hand those files back.
849
849
  const typescriptHandler = Module._extensions[".ts"];
850
+ // The tests a process serves reach the project files it loaded and read: a
851
+ // mock server a suite starts, the app server. Where SUPERCOV_SUPPORT_DIR is
852
+ // set this process notes them in a file of its own, which the loader adds
853
+ // its modules to and the runtime the tests the process served; a change to
854
+ // one of those files then affects those tests alone (`tests affected`,
855
+ // `merge`).
856
+ const supportDirectory = process.env.SUPERCOV_SUPPORT_DIR;
857
+ let supportFile;
858
+ if (supportDirectory) {
859
+ supportFile = path.join(supportDirectory, `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.jsonl`);
860
+ // This process's own, not a parent's it inherited.
861
+ process.env.__SUPERCOV_SUPPORT_FILE = supportFile;
862
+ try {
863
+ fs.mkdirSync(supportDirectory, { recursive: true });
864
+ }
865
+ catch {
866
+ // Then nothing is noted, and every test counts as before.
867
+ }
868
+ const projectRoot = process.env.SUPERCOV_PROJECT_ROOT;
869
+ const read = new Set();
870
+ const noteRead = (target) => {
871
+ try {
872
+ const file = typeof target === "string"
873
+ ? resolve(target)
874
+ : target instanceof URL && target.protocol === "file:"
875
+ ? fileURLToPath(target)
876
+ : undefined;
877
+ // The copy lives under `.supercov/`: what is the project's is
878
+ // decided by the project root.
879
+ const inside = file && projectRoot && file.startsWith(projectRoot + sep) ? file.slice(projectRoot.length + 1) : undefined;
880
+ if (!inside || inside.startsWith(`.supercov${sep}`) || inside.split(sep).includes("node_modules") || read.has(file))
881
+ return;
882
+ read.add(file);
883
+ fs.appendFileSync(supportFile, `${JSON.stringify({ read: file })}\n`);
884
+ }
885
+ catch {
886
+ // A read not noted counts for every test, as before.
887
+ }
888
+ };
889
+ for (const name of ["readFileSync", "readFile"]) {
890
+ const original = fs[name];
891
+ fs[name] = function supercovNotedRead(target, ...rest) {
892
+ noteRead(target);
893
+ return original.call(this, target, ...rest);
894
+ };
895
+ }
896
+ const readPromised = fs.promises.readFile;
897
+ fs.promises.readFile = function supercovNotedRead(target, ...rest) {
898
+ noteRead(target);
899
+ return readPromised.call(this, target, ...rest);
900
+ };
901
+ syncBuiltinESMExports();
902
+ }
850
903
  register(new URL("./resolve-loader.mjs", import.meta.url), {
851
- data: { typescriptRequireHook: typeof typescriptHandler === "function" && typescriptHandler.name !== "loadTS" },
904
+ data: {
905
+ typescriptRequireHook: typeof typescriptHandler === "function" && typescriptHandler.name !== "loadTS",
906
+ supportFile,
907
+ },
852
908
  });
853
909
  if (process.env.SUPERCOV_DEBUG === "1") {
854
910
  console.error("[supercov] preload", { entrypoint });
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
2
  import { dirname, resolve as resolvePath } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  const GENERATED_TARGET = "__SUPERCOV_PLAYWRIGHT_MODULE__";
@@ -27,8 +27,40 @@ function belongsToProject(parentURL) {
27
27
  return (parentURL.startsWith(projectURL) && !parentURL.startsWith(generatedURL));
28
28
  }
29
29
  let typescriptRequireHook = false;
30
+ // Where this process notes the project modules it loads (`register.mjs`
31
+ // names it): a mock server a suite starts is reached by the tests it serves,
32
+ // and a change to a file it loaded is a change for them alone.
33
+ let supportFile;
34
+ const supportModules = new Set();
35
+ // A project file, not a dependency nor Supercov's own: the copy itself lives
36
+ // under `.supercov/`, so the project root decides.
37
+ function projectFile(file) {
38
+ if (!PROJECT_ROOT || !file.startsWith(PROJECT_ROOT))
39
+ return false;
40
+ const relative = file.slice(PROJECT_ROOT.length).replace(/^[\\/]/, "");
41
+ return relative !== "" && !/^\.supercov[\\/]/.test(relative) && !/(?:^|[\\/])node_modules[\\/]/.test(relative);
42
+ }
43
+ function noteLoaded(url) {
44
+ if (!supportFile || !url.startsWith("file:") || supportModules.has(url))
45
+ return;
46
+ supportModules.add(url);
47
+ try {
48
+ if (!projectFile(fileURLToPath(url)))
49
+ return;
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ try {
55
+ appendFileSync(supportFile, `${JSON.stringify({ loaded: url })}\n`);
56
+ }
57
+ catch {
58
+ // Not recorded: the file counts for every test, as before.
59
+ }
60
+ }
30
61
  export function initialize(data) {
31
62
  typescriptRequireHook = data?.typescriptRequireHook === true;
63
+ supportFile = typeof data?.supportFile === "string" ? data.supportFile : undefined;
32
64
  }
33
65
  const packageTypes = new Map();
34
66
  function packageType(directory) {
@@ -144,6 +176,7 @@ function mapToCopy(source, filename) {
144
176
  }
145
177
  export async function load(url, context, nextLoad) {
146
178
  const result = await nextLoad(url, context);
179
+ noteLoaded(url);
147
180
  if (!NODE_COVERAGE_FILTERS || result.source == null || !url.startsWith("file:"))
148
181
  return result;
149
182
  const text = typeof result.source === "string" ? result.source : Buffer.from(result.source).toString("utf8");
@@ -1425,6 +1425,55 @@ emitterContextMaps.set(runtimeInstance, emitterCoverageContexts);
1425
1425
  if (runtimeGlobal.__SUPERCOV_EMITTER_PATCHED_INSTANCES__ == null)
1426
1426
  runtimeGlobal.__SUPERCOV_EMITTER_PATCHED_INSTANCES__ = new Set();
1427
1427
  const patchedEmitterInstances = runtimeGlobal.__SUPERCOV_EMITTER_PATCHED_INSTANCES__;
1428
+ // Which tests this process served, and when it served a request that named
1429
+ // none, in the file `register.mjs` named for it: the project files it loaded
1430
+ // and read are reached by those tests, and a change to one is a change for
1431
+ // them alone (`tests affected`, `merge`). Its CommonJS modules are noted
1432
+ // here, once each, as requests come; the loader notes its ES modules.
1433
+ var supportNoted = { served: new Set(), modules: new Set(), lastUnscoped: 0 };
1434
+ function noteSupport(scope) {
1435
+ const file = typeof process !== "undefined" && process.env ? process.env.__SUPERCOV_SUPPORT_FILE : void 0;
1436
+ if (!file)
1437
+ return;
1438
+ const fs = getFs();
1439
+ if (!fs)
1440
+ return;
1441
+ const lines = [];
1442
+ if (scope) {
1443
+ if (!supportNoted.served.has(scope.testId)) {
1444
+ supportNoted.served.add(scope.testId);
1445
+ lines.push({ served: scope.testId });
1446
+ }
1447
+ } else {
1448
+ const now = Date.now();
1449
+ if (now - supportNoted.lastUnscoped >= 5) {
1450
+ supportNoted.lastUnscoped = now;
1451
+ lines.push({ unscoped: now });
1452
+ }
1453
+ }
1454
+ try {
1455
+ const moduleBuiltin = typeof process.getBuiltinModule === "function" ? process.getBuiltinModule("node:module") : void 0;
1456
+ const cache = moduleBuiltin && moduleBuiltin._cache;
1457
+ const root = process.env.SUPERCOV_PROJECT_ROOT;
1458
+ if (cache && root) {
1459
+ for (const name of Object.keys(cache)) {
1460
+ // The copy lives under `.supercov/`: the project root decides.
1461
+ const inside = name.indexOf(root) === 0 ? name.slice(root.length).replace(/^[\\/]/, "") : "";
1462
+ if (supportNoted.modules.has(name) || inside === "" || /^\.supercov[\\/]/.test(inside) || /(?:^|[\\/])node_modules[\\/]/.test(inside))
1463
+ continue;
1464
+ supportNoted.modules.add(name);
1465
+ lines.push({ loaded: name });
1466
+ }
1467
+ }
1468
+ } catch (e) {
1469
+ }
1470
+ if (lines.length === 0)
1471
+ return;
1472
+ try {
1473
+ fs.appendFileSync(file, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
1474
+ } catch (e) {
1475
+ }
1476
+ }
1428
1477
  function installNodeRequestPropagation() {
1429
1478
  if (isBrowser || patchedEmitterInstances.has(runtimeInstance) || typeof process === "undefined") return;
1430
1479
  const builtin = (name) => typeof process.getBuiltinModule === "function" ? process.getBuiltinModule(name) : void 0;
@@ -1443,6 +1492,7 @@ function installNodeRequestPropagation() {
1443
1492
  // HTTP headers establish ownership even when Express/SDK listeners and
1444
1493
  // the server process were created by a shared before hook.
1445
1494
  const incoming = requestCoverageContext(args[0]);
1495
+ noteSupport(incoming && incoming.scope);
1446
1496
  if (incoming) context = incoming.scope ? incoming : context != null ? context : currentRequestContext();
1447
1497
  if (incoming && incoming.scope)
1448
1498
  noteServedAttempt(incoming.scope);