testguard-cli 0.18.1 → 0.18.3
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 +25 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +15 -1
- package/src/probe/runners/node/direct.mjs +55 -0
- package/src/probe/runners/node/entry.mjs +6 -0
- package/src/probe/runners/node-test.mjs +20 -6
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.18.3] - 2026-10-05
|
|
11
|
+
|
|
12
|
+
Automated weekly release — everything merged since `v0.18.2`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- chore(homebrew): sha256 for v0.18.2 (#202)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.18.2] - 2026-10-02
|
|
20
|
+
|
|
21
|
+
Automated weekly release — everything merged since `v0.18.1`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- perf: use one fresh process for single-entry native probes (#200)
|
|
26
|
+
- perf: focus gate annotation and claim selection defenders (#198)
|
|
27
|
+
- chore(homebrew): sha256 for v0.18.1 (#197)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Performance
|
|
31
|
+
|
|
32
|
+
- 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.
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
10
35
|
## [0.18.1] - 2026-10-01
|
|
11
36
|
|
|
12
37
|
Automated weekly release — everything merged since `v0.18.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.
|
|
130
|
+
| GitHub Action | `uses: raccioly/testguard@v0.18.3` — 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.
|
|
132
|
+
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.18.3/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.
|
|
788
|
+
- uses: raccioly/testguard@v0.18.3
|
|
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.
|
|
793
|
+
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.18.3/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.
|
|
3
|
+
"version": "0.18.3",
|
|
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",
|
package/spec/GATE-SEMANTICS.md
CHANGED
|
@@ -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.
|
|
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,
|
|
@@ -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
|
-
|
|
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
|
}
|