testguard-cli 0.18.0 → 0.18.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
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.2] - 2026-10-02
11
+
12
+ Automated weekly release — everything merged since `v0.18.1`.
13
+
14
+ ### Changed
15
+
16
+ - perf: use one fresh process for single-entry native probes (#200)
17
+ - perf: focus gate annotation and claim selection defenders (#198)
18
+ - chore(homebrew): sha256 for v0.18.1 (#197)
19
+
20
+
21
+ ### Performance
22
+
23
+ - Eligible single-file native Node confirmations use one fresh process, avoiding a second process launch while preserving real CLI entry identity, ordinary tests beside `.only`, import and late-error refusals, budgets, and process cleanup. Discovery, multiple files and older runtimes keep the existing isolated route.
24
+ - Focus gate coverage, repeated claim selection and annotation ID bounds defenders on their relevant checks, retaining all original assertions in the mandatory acceptance suite and all fault recipes.
25
+
26
+ ## [0.18.1] - 2026-10-01
27
+
28
+ Automated weekly release — everything merged since `v0.18.0`.
29
+
30
+ ### Changed
31
+
32
+ - test: consolidate native runtime acceptance checks
33
+ - perf: isolate native admission mutation defenders
34
+ - perf: size bounded report buffers to observed files
35
+ - chore(homebrew): sha256 for v0.18.0 (#194)
36
+
37
+
38
+ ### Changed
39
+
40
+ - Runner report and discovery-config reads reserve memory for the observed
41
+ file size instead of the maximum allowed size, retaining the hard byte
42
+ ceiling and checking file growth before parsing.
43
+ - Native-runner admission self-probes use focused defenders, preserving the
44
+ fault recipes and full runner acceptance coverage while avoiding unrelated
45
+ timeout and process-cleanup checks for each admission confirmation.
46
+
10
47
  ## [0.18.0] - 2026-10-01
11
48
 
12
49
  Automated weekly release — everything merged since `v0.17.0`.
package/README.md CHANGED
@@ -127,9 +127,9 @@ raises coverage. TestGuard admits it because it fails when the claim is false.
127
127
  | npm | `npm i -D testguard-cli` then `npx testguard probe` |
128
128
  | pip | `pip install testguard-cli` then `testguard probe` (needs Node ≥ 20) |
129
129
  | Homebrew | `brew tap raccioly/tap && brew install testguard` |
130
- | GitHub Action | `uses: raccioly/testguard@v0.18.0` — see [`action.yml`](./action.yml) |
130
+ | GitHub Action | `uses: raccioly/testguard@v0.18.2` — see [`action.yml`](./action.yml) |
131
131
  | pre-commit | `repo: https://github.com/raccioly/testguard`, hooks `testguard-claims`, `testguard-probe` |
132
- | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.18.0/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
132
+ | GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.18.2/packaging/gitlab/testguard.gitlab-ci.yml` with `inputs:` — see [`packaging/gitlab/`](./packaging/gitlab/testguard.gitlab-ci.yml) |
133
133
 
134
134
  Projects that set `min-release-age` in `.npmrc` cannot see a version published
135
135
  less than that many days ago (`ENOVERSIONS`); install that one with
@@ -785,12 +785,12 @@ detected base that does not resolve is a warning for `status` and `brief`
785
785
  # GitHub Actions
786
786
  - uses: actions/checkout@v4
787
787
  with: { fetch-depth: 0 }
788
- - uses: raccioly/testguard@v0.18.0
788
+ - uses: raccioly/testguard@v0.18.2
789
789
  with: { command: gate }
790
790
 
791
791
  # GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
792
792
  include:
793
- - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.18.0/packaging/gitlab/testguard.gitlab-ci.yml'
793
+ - remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.18.2/packaging/gitlab/testguard.gitlab-ci.yml'
794
794
  inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
795
795
  ```
796
796
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testguard-cli",
3
- "version": "0.18.0",
3
+ "version": "0.18.2",
4
4
  "description": "Breaks your code on purpose and reports every promise your tests did not notice breaking. Mutation testing bound to the claims a project makes — vitest, jest, Playwright, pytest and unittest.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -321,7 +321,21 @@ Rules that follow from the table:
321
321
  supports plain JavaScript under Node's default collection, without custom
322
322
  loaders, `NODE_OPTIONS` or `NODE_PATH`; it is never selected by `auto`.
323
323
  Each confirmation uses fresh, isolated Node test processes under the
324
- existing worker ceiling. Reports count executed test results, not individual
324
+ existing worker ceiling. On Node22.8+, Node24 and Node26, a single selected
325
+ entry can run inside one fresh process through a reporting preload and the
326
+ public in-process API; Node evaluates the actual CLI entry, preserving
327
+ CommonJS `require.main`, ESM `import.meta.main` and argv identity. This process
328
+ serves no other entry or confirmation. Discovery, multiple
329
+ entries and other runtime families retain the isolated controller/worker
330
+ route. Single-entry execution preserves ordinary tests beside `.only`,
331
+ records entry provenance before evaluation, monitors entry-load failures
332
+ after test registration, and publishes only at process exit when no
333
+ out-of-test failure or incompatible exit code occurred. A complete stream
334
+ cannot turn a later exception, rejection or retained-handle timeout into a
335
+ kill. Report and entry destinations are captured before project evaluation;
336
+ project environment edits cannot redirect these owned outputs. Both routes
337
+ bind their implementation to native discovery identity.
338
+ Reports count executed test results, not individual
325
339
  assertion calls: suite and file-container summaries are not extra tests,
326
340
  and skipped/TODO results cannot establish a green defender by themselves.
327
341
  A test-body failure counts as a rejection; a load error, hook failure,
@@ -130,12 +130,21 @@ const readBoundedFile = (path, maxBytes, label, { optional = false } = {}) => {
130
130
  const stat = fstatSync(descriptor);
131
131
  if (!stat.isFile()) throw new DiscoveryError(`${label} is not a regular file`);
132
132
  if (stat.size > maxBytes) throw new DiscoveryError(`${label} exceeded ${maxBytes} bytes`);
133
- const bytes = Buffer.alloc(maxBytes + 1);
133
+ // Reserve the observed file size, not the allowance for the largest report.
134
+ // The extra byte detects growth; a full buffer grows within the same hard
135
+ // ceiling so a concurrently appended suffix can never be silently dropped.
136
+ let bytes = Buffer.alloc(stat.size + 1);
134
137
  let length = 0;
135
138
  while (length < bytes.length) {
136
139
  const read = readSync(descriptor, bytes, length, bytes.length - length, null);
137
140
  if (read === 0) break;
138
141
  length += read;
142
+ if (length > maxBytes) throw new DiscoveryError(`${label} exceeded ${maxBytes} bytes`);
143
+ if (length === bytes.length) {
144
+ const grown = Buffer.alloc(Math.min(maxBytes + 1, Math.max(4096, bytes.length * 2)));
145
+ bytes.copy(grown, 0, 0, length);
146
+ bytes = grown;
147
+ }
139
148
  }
140
149
  if (length > maxBytes) throw new DiscoveryError(`${label} exceeded ${maxBytes} bytes`);
141
150
  return bytes.subarray(0, length);
@@ -0,0 +1,55 @@
1
+ import { appendFileSync, writeFileSync } from 'node:fs';
2
+ import { readBoundedJsonFile } from '../discovery.mjs';
3
+ import { createReport } from './report.mjs';
4
+
5
+ // This preload returns before Node evaluates its actual CLI entry. Running the
6
+ // file as the entry preserves require.main, import.meta.main and process.argv.
7
+ const file = process.argv[1];
8
+ if (!file) throw new Error('direct node-test requires an entry');
9
+ const entriesFile = process.env.TESTGUARD_NODE_ENTRIES;
10
+ const reportFile = process.env.TESTGUARD_NODE_REPORT;
11
+ const diagnosticFile = `${entriesFile}.error`;
12
+ const report = createReport({ projectDir: process.cwd(), version: process.versions.node });
13
+ let document;
14
+ let outsideFailure = false;
15
+ process.on('uncaughtExceptionMonitor', (error) => {
16
+ outsideFailure = true;
17
+ writeFileSync(diagnosticFile, JSON.stringify({ name: String(error?.name ?? 'Error').slice(0, 100), message: String(error?.message ?? error).slice(0, 1000) }), { mode: 0o600 });
18
+ });
19
+ process.on('unhandledRejection', () => { outsideFailure = true; process.exitCode = 1; });
20
+ // A complete stream is insufficient: a retained timer can fail or hang later.
21
+ // Publish only at process exit, after CLI loading and out-of-test failures settle.
22
+ process.on('exit', (code) => {
23
+ if (!document || outsideFailure || code !== (document.failed ? 1 : 0)) return;
24
+ const text = JSON.stringify(document);
25
+ if (Buffer.byteLength(text) > 16 * 1024 * 1024) return;
26
+ writeFileSync(reportFile, text, { mode: 0o600 });
27
+ });
28
+ const receipt = JSON.stringify({ file, pid: process.pid, version: process.versions.node });
29
+ if (Buffer.byteLength(receipt) > 8192) throw new Error('node-test entry receipt exceeded its byte bound');
30
+ appendFileSync(entriesFile, `${receipt},`);
31
+
32
+ // Node24+ otherwise enables implicit .only filtering for isolation:none,
33
+ // even with run({only:false}). Preserve isolated-worker filtering semantics.
34
+ process.env.NODE_TEST_CONTEXT = 'child-v8';
35
+ const { run } = await import('node:test');
36
+ const stream = run({ files: [], isolation: 'none', only: false, setup: () => new Promise((resolve) => {
37
+ // Keep the public root open until Node has loaded the real CLI entry. An
38
+ // awaited stream here would block entry evaluation; the drain runs below.
39
+ process.once('beforeExit', resolve);
40
+ }) });
41
+ (async () => {
42
+ try {
43
+ for await (const event of stream) report.accept(event);
44
+ appendFileSync(entriesFile, 'null]}');
45
+ const entries = readBoundedJsonFile(entriesFile).entries;
46
+ if (!Array.isArray(entries) || entries.length !== 2 || entries.pop() !== null) throw new Error('incomplete direct node-test entry receipt');
47
+ document = report.finish(entries);
48
+ if (document.failed) process.exitCode = 1;
49
+ } catch (error) {
50
+ outsideFailure = true;
51
+ stream.destroy();
52
+ process.stderr.write(`node-test reporter failed: ${String(error.message ?? error).slice(0, 1000)}\n`);
53
+ process.exitCode = 1;
54
+ }
55
+ })();
@@ -9,3 +9,9 @@ if (process.env.NODE_TEST_CONTEXT?.startsWith('child')) {
9
9
  }
10
10
  appendFileSync(process.env.TESTGUARD_NODE_ENTRIES, `${receipt},`);
11
11
  }
12
+
13
+ // A fresh single-entry process initializes reporting before its real CLI
14
+ // entry. Inherited child context must not bootstrap a second reporter in a fork.
15
+ if (process.env.TESTGUARD_NODE_DIRECT === '1' && process.env.NODE_TEST_CONTEXT === '') {
16
+ await import('./direct.mjs');
17
+ }
@@ -1,10 +1,10 @@
1
- import { closeSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { runProcess } from './shared.mjs';
7
- import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, runDiscoveryProcess } from './discovery.mjs';
7
+ import { createDiscoveryManifest, DiscoveryError, hashDiscoveryConfigs, normalizeDiscoveredFiles, readBoundedJsonFile, runDiscoveryProcess } from './discovery.mjs';
8
8
 
9
9
  export const name = 'node-test';
10
10
  export const testGlobs = []; // Native collection is authoritative; never guess from globs.
@@ -12,6 +12,11 @@ const HERE = join(dirname(fileURLToPath(import.meta.url)), 'node');
12
12
  const RUNNER = join(HERE, 'runner.mjs');
13
13
  const ENTRY = join(HERE, 'entry.mjs');
14
14
  const VERSION = process.versions.node;
15
+ /** Admit only maintained runtime families with the in-process public API. */
16
+ export function supportsSingleProcess(version = VERSION) {
17
+ const [major, minor] = version.split('.').map(Number);
18
+ return major === 22 && minor >= 8 || major === 24 || major === 26;
19
+ }
15
20
  const strategyHash = () => {
16
21
  const digest = createHash('sha256');
17
22
  const descriptor = openSync(process.execPath, 'r');
@@ -20,7 +25,7 @@ const strategyHash = () => {
20
25
  let length;
21
26
  while ((length = readSync(descriptor, bytes, 0, bytes.length, null)) > 0) digest.update(bytes.subarray(0, length));
22
27
  } finally { closeSync(descriptor); }
23
- for (const file of [fileURLToPath(import.meta.url), ...['runner.mjs', 'entry.mjs', 'report.mjs'].map((file) => join(HERE, file)), ...['shared.mjs', 'discovery.mjs', 'lifecycle.mjs'].map((file) => join(HERE, '..', file))]) digest.update(readFileSync(file));
28
+ for (const file of [fileURLToPath(import.meta.url), ...['runner.mjs', 'direct.mjs', 'entry.mjs', 'report.mjs'].map((file) => join(HERE, file)), ...['shared.mjs', 'discovery.mjs', 'lifecycle.mjs'].map((file) => join(HERE, '..', file))]) digest.update(readFileSync(file));
24
29
  return digest.digest('hex');
25
30
  };
26
31
  const admissionError = () => Number(VERSION.split('.')[0]) < 20 ? 'node-test requires Node20 or newer'
@@ -37,15 +42,16 @@ const receipts = () => {
37
42
  writeFileSync(file, '{"entries":[', { mode: 0o600 });
38
43
  return { dir, file };
39
44
  };
40
- const environment = (file, collect, workers) => ({
45
+ const environment = (file, collect, workers, direct = false) => ({
41
46
  TESTGUARD_NODE_ENTRIES: file,
42
47
  TESTGUARD_NODE_COLLECT: collect ? '1' : '0',
43
48
  TESTGUARD_NODE_WORKERS: String(workers),
44
49
  TESTGUARD_NODE_REPORT: '{out}',
50
+ TESTGUARD_NODE_DIRECT: direct ? '1' : '0',
45
51
  NODE_TEST_CONTEXT: '',
46
52
  NODE_DISABLE_COMPILE_CACHE: '1',
47
53
  });
48
- export const argvFor = (projectDir, files) => [process.execPath, `--import=${ENTRY}`, RUNNER, ...files.map((file) => resolve(realpathSync(projectDir), file))];
54
+ export const argvFor = (projectDir, files, { direct = false } = {}) => [process.execPath, `--import=${ENTRY}`, ...(!direct ? [RUNNER] : []), ...files.map((file) => resolve(realpathSync(projectDir), file))];
49
55
 
50
56
  /** Reject unsupported/incomplete envelopes before deriving any verdict inputs. */
51
57
  export function validateReport(report) {
@@ -120,11 +126,19 @@ export async function run(opts) {
120
126
  const files = normalizeDiscoveredFiles(opts.projectDir, opts.files);
121
127
  const spool = receipts();
122
128
  try {
123
- return await runProcess({ ...opts, files, cleanupOnClose: true, env: environment(spool.file, false, opts.serial ? 1 : opts.workers ?? 1), argv: (selected) => argvFor(opts.projectDir, selected), parse: (report, durationMs) => {
129
+ const direct = files.length === 1 && supportsSingleProcess();
130
+ const result = await runProcess({ ...opts, files, cleanupOnClose: true, env: environment(spool.file, false, opts.serial ? 1 : opts.workers ?? 1, direct), argv: (selected) => argvFor(opts.projectDir, selected, { direct }), parse: (report, durationMs) => {
124
131
  validateReport(report);
125
132
  const entries = normalizeDiscoveredFiles(opts.projectDir, report.entries.map((entry) => entry.file));
126
133
  if (entries.length !== report.entries.length || JSON.stringify(entries) !== JSON.stringify(files)) throw new DiscoveryError('node-test did not execute every selected entry exactly');
127
134
  return parseReport(report, durationMs);
128
135
  } });
136
+ // CLI loading can fail before a complete stream exists. Keep the actual
137
+ // bounded exception identity rather than Node's trailing version banner.
138
+ if (result.run.outcome === 'error' && existsSync(`${spool.file}.error`)) {
139
+ const diagnostic = readBoundedJsonFile(`${spool.file}.error`, 8192);
140
+ if (typeof diagnostic.name === 'string' && diagnostic.name.length <= 100 && typeof diagnostic.message === 'string' && diagnostic.message.length <= 1000) result.loadMessage = `${diagnostic.name}: ${diagnostic.message}`;
141
+ }
142
+ return result;
129
143
  } finally { rmSync(spool.dir, { recursive: true, force: true }); }
130
144
  }