testguard-cli 0.10.2 → 0.10.4
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 +26 -0
- package/README.md +14 -6
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +8 -3
- package/src/commands/brief.mjs +1 -1
- package/src/commands/gate.mjs +3 -3
- package/src/commands/probe.mjs +1 -1
- package/src/commands/status.mjs +1 -1
- package/src/gate/changed.mjs +55 -10
- package/src/init/templates/SKILL.md +1 -1
- package/src/mcp/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.4] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
Automated weekly release — everything merged since `v0.10.3`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- chore(homebrew): sha256 for v0.10.3 (#147)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.10.3] - 2026-09-20
|
|
20
|
+
|
|
21
|
+
Automated weekly release — everything merged since `v0.10.2`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- fix(gate): infer safe local change base (#145)
|
|
26
|
+
- chore(homebrew): sha256 for v0.10.2 (#144)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Bare `testguard gate` now discovers an ordinary clone's remote default
|
|
32
|
+
branch and reports how it chose the comparison. It still fails closed on
|
|
33
|
+
ambiguous Git state: the default branch is not compared to itself, and a
|
|
34
|
+
feature branch's same-name tracking ref is never accepted as its base.
|
|
35
|
+
|
|
10
36
|
## [0.10.2] - 2026-09-20
|
|
11
37
|
|
|
12
38
|
Automated weekly release — everything merged since `v0.10.1`.
|
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.10.
|
|
130
|
+
| GitHub Action | `uses: raccioly/testguard@v0.10.4` — 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.10.
|
|
132
|
+
| GitLab CI | `include: - remote: https://raw.githubusercontent.com/raccioly/testguard/v0.10.4/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
|
|
@@ -460,9 +460,17 @@ state when an exact fault anchor is missing or ambiguous, and points at the
|
|
|
460
460
|
fault definition to repair. Repair preserves the fault's meaning and requires
|
|
461
461
|
a re-probe; there is deliberately no `--fix`.
|
|
462
462
|
|
|
463
|
-
**
|
|
463
|
+
**The base is detected when Git records it.** An explicit `--changed` wins,
|
|
464
|
+
then `TESTGUARD_CHANGED_REF`, GitHub Actions (`GITHUB_BASE_REF`) and GitLab
|
|
464
465
|
merge request pipelines (`CI_MERGE_REQUEST_DIFF_BASE_SHA`, then
|
|
465
|
-
`CI_MERGE_REQUEST_TARGET_BRANCH_NAME`)
|
|
466
|
+
`CI_MERGE_REQUEST_TARGET_BRANCH_NAME`). In an ordinary clone on a feature
|
|
467
|
+
branch, bare `testguard gate` uses the configured remote's symbolic default
|
|
468
|
+
branch (normally `origin/main`) and says how it chose it. If that symbolic ref
|
|
469
|
+
is absent, a configured upstream is used only when its branch name differs
|
|
470
|
+
from the current branch. A feature branch's same-name tracking ref and the
|
|
471
|
+
default branch itself are never selected: either could produce a misleading
|
|
472
|
+
empty diff. In those ambiguous cases, pass `--changed` explicitly.
|
|
473
|
+
|
|
466
474
|
The base must exist locally: GitHub — `actions/checkout` with `fetch-depth: 0`;
|
|
467
475
|
GitLab — the diff base sha needs nothing extra on a merge request pipeline,
|
|
468
476
|
the branch name needs `GIT_DEPTH: 0` or a `git fetch origin <target>`. A
|
|
@@ -473,12 +481,12 @@ detected base that does not resolve is a warning for `status` and `brief`
|
|
|
473
481
|
# GitHub Actions
|
|
474
482
|
- uses: actions/checkout@v4
|
|
475
483
|
with: { fetch-depth: 0 }
|
|
476
|
-
- uses: raccioly/testguard@v0.10.
|
|
484
|
+
- uses: raccioly/testguard@v0.10.4
|
|
477
485
|
with: { command: gate }
|
|
478
486
|
|
|
479
487
|
# GitLab CI — the component-shaped template: gate + probe, brief as an artifact and, opted in, as a merge-request note
|
|
480
488
|
include:
|
|
481
|
-
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.10.
|
|
489
|
+
- remote: 'https://raw.githubusercontent.com/raccioly/testguard/v0.10.4/packaging/gitlab/testguard.gitlab-ci.yml'
|
|
482
490
|
inputs: { dir: backend, post_note: true } # post_note needs TESTGUARD_GITLAB_TOKEN (api scope); one note, updated in place
|
|
483
491
|
```
|
|
484
492
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testguard-cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
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
|
@@ -402,9 +402,14 @@ Rules:
|
|
|
402
402
|
4. **Never a silent pass on nothing.** A non-empty change whose files were
|
|
403
403
|
all excluded passes with an explicit "0 evaluated" line; a tool offers a
|
|
404
404
|
strict mode that fails it instead. An empty change is an honest `0`.
|
|
405
|
-
5. **The reference is
|
|
406
|
-
|
|
407
|
-
|
|
405
|
+
5. **The reference is inferred only from recorded intent.** Resolution stops
|
|
406
|
+
at the first available source: an explicit flag, `TESTGUARD_CHANGED_REF`, a
|
|
407
|
+
CI-provided base, the configured remote's symbolic default branch, then a
|
|
408
|
+
configured upstream whose branch name differs from the current branch.
|
|
409
|
+
Local discovery never compares a branch to its same-name remote-tracking
|
|
410
|
+
ref: that ref may already contain the whole change and yield a false empty
|
|
411
|
+
diff. It also declines to compare the default branch to itself. When Git
|
|
412
|
+
records no safe local base, the gate exits `3` and asks for `--changed`.
|
|
408
413
|
6. **Unclaimed changes precede every evidence state.** When the status
|
|
409
414
|
document knows a reference and finds uncovered files, its state is
|
|
410
415
|
`unclaimed-changes` and its next action is to write the claim, before any
|
package/src/commands/brief.mjs
CHANGED
|
@@ -13,7 +13,7 @@ export async function briefCommand({ projectDir, values, version }, io) {
|
|
|
13
13
|
// A detected base that does not resolve is silent here: the brief is a
|
|
14
14
|
// session-start hook's output and must never add noise; `status` and
|
|
15
15
|
// `gate` are where that warning is printed.
|
|
16
|
-
status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: values.evidence ? evPath : undefined }));
|
|
16
|
+
status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: values.evidence ? evPath : undefined }));
|
|
17
17
|
} catch {
|
|
18
18
|
status = undefined; // a brief must never fail because status could not be computed
|
|
19
19
|
}
|
package/src/commands/gate.mjs
CHANGED
|
@@ -16,14 +16,14 @@ export async function gateCommand({ projectDir, values, version }, io) {
|
|
|
16
16
|
io.out('Non-source files (anything but .js .mjs .cjs .ts .mts .cts .jsx .tsx) are excluded before these patterns apply.');
|
|
17
17
|
return 0;
|
|
18
18
|
}
|
|
19
|
-
const resolved = resolveChangedRef({ explicit: values.changed });
|
|
19
|
+
const resolved = resolveChangedRef({ explicit: values.changed, projectDir });
|
|
20
20
|
if (!resolved) {
|
|
21
|
-
io.err('gate needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF.
|
|
21
|
+
io.err('gate needs a reference to measure the change against: --changed <ref> (e.g. origin/main), or set TESTGUARD_CHANGED_REF. CI bases and a safe local remote default or differently named upstream are detected automatically.');
|
|
22
22
|
return 3;
|
|
23
23
|
}
|
|
24
24
|
const ref = resolved.ref;
|
|
25
25
|
// The gate itself never degrades: a detected reference that does not resolve is exit 2 here, because the gate's whole job is the measurement.
|
|
26
|
-
if (!resolved.required && !values.json && !values.quiet) io.err(
|
|
26
|
+
if (!resolved.required && !values.json && !values.quiet) io.err(`gate: comparing against ${ref} (${resolved.from})`);
|
|
27
27
|
const doc = computeChangedGate({
|
|
28
28
|
projectDir,
|
|
29
29
|
ref,
|
package/src/commands/probe.mjs
CHANGED
|
@@ -106,7 +106,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
106
106
|
const g = gate(evidence.records, baseline, { severityFloor: values.severity });
|
|
107
107
|
const scope = partialScope(only, evidence.records);
|
|
108
108
|
if (values.json) {
|
|
109
|
-
const status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
|
|
109
|
+
const status = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
|
|
110
110
|
const doc = { ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0, ...(scope ? { scope } : {}) }, ...(values.cost ? { cost: costReport(evidence.records) } : {}) };
|
|
111
111
|
const result = validate('status', doc);
|
|
112
112
|
if (!result.ok) throw new Error(`probe JSON document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
package/src/commands/status.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
|
|
|
5
5
|
|
|
6
6
|
export async function statusCommand({ projectDir, values, version }, io) {
|
|
7
7
|
const evidence = values.evidence ? resolve(values.evidence) : undefined;
|
|
8
|
-
const doc = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence }), io.err);
|
|
8
|
+
const doc = withChangedRef(resolveChangedRef({ explicit: values.changed, projectDir }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence }), io.err);
|
|
9
9
|
const result = validate('status', doc);
|
|
10
10
|
if (!result.ok) throw new Error(`status document does not conform: ${result.errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`);
|
|
11
11
|
io.out(values.json ? JSON.stringify(doc, null, 2) : renderStatus(doc));
|
package/src/gate/changed.mjs
CHANGED
|
@@ -50,10 +50,10 @@ export const isDefaultExcluded = (file) => DEFAULT_EXCLUDE_RES.some((re) => re.t
|
|
|
50
50
|
export const defaultIgnorePath = (projectDir) => join(projectDir, 'testguard.ignore.json');
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* The reference the change is measured against.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
53
|
+
* The reference the change is measured against. Environment overrides and CI
|
|
54
|
+
* bases are handled here; local Git discovery is separate because it needs a
|
|
55
|
+
* project directory and must distinguish a base branch from a feature branch's
|
|
56
|
+
* same-name tracking ref.
|
|
57
57
|
*/
|
|
58
58
|
export function detectChangedRef(env = process.env) {
|
|
59
59
|
if (env.TESTGUARD_CHANGED_REF) return { ref: env.TESTGUARD_CHANGED_REF, from: 'TESTGUARD_CHANGED_REF' };
|
|
@@ -68,18 +68,63 @@ export function detectChangedRef(env = process.env) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
const tryGit = (args, cwd) => {
|
|
72
|
+
try {
|
|
73
|
+
return git(args, cwd) || null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Infer a local change base only when Git records enough intent to do so
|
|
81
|
+
* without turning an empty feature-branch tracking diff into a false pass.
|
|
82
|
+
*
|
|
83
|
+
* The configured upstream selects the remote whose symbolic HEAD we inspect.
|
|
84
|
+
* Without one, exactly one remote HEAD is unambiguous. A branch never compares
|
|
85
|
+
* against that remote's same-named branch: on the default branch that asks no
|
|
86
|
+
* useful question, and on a feature branch its tracking ref may already contain
|
|
87
|
+
* the entire change. An upstream is a safe fallback only when its branch name
|
|
88
|
+
* differs from the local branch name (for example topic -> origin/main).
|
|
89
|
+
*/
|
|
90
|
+
export function detectLocalChangedRef(projectDir) {
|
|
91
|
+
if (!projectDir) return null;
|
|
92
|
+
const root = tryGit(['rev-parse', '--show-toplevel'], projectDir);
|
|
93
|
+
if (!root) return null;
|
|
94
|
+
const branch = tryGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
|
|
95
|
+
if (!branch) return null;
|
|
96
|
+
|
|
97
|
+
const tracking = tryGit(['for-each-ref', '--format=%(upstream:short)%00%(upstream:remotename)', `refs/heads/${branch}`], root);
|
|
98
|
+
const [upstream, upstreamRemote] = tracking?.split('\0') ?? [];
|
|
99
|
+
let remote = upstreamRemote && upstreamRemote !== '.' ? upstreamRemote : null;
|
|
100
|
+
|
|
101
|
+
const remoteHeads = (tryGit(['for-each-ref', '--format=%(refname)', 'refs/remotes'], root) ?? '')
|
|
102
|
+
.split('\n')
|
|
103
|
+
.filter((ref) => ref.endsWith('/HEAD'));
|
|
104
|
+
if (!remote && remoteHeads.length === 1) remote = remoteHeads[0].slice('refs/remotes/'.length, -'/HEAD'.length);
|
|
105
|
+
|
|
106
|
+
const defaultRef = remote
|
|
107
|
+
? tryGit(['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], root)
|
|
108
|
+
: null;
|
|
109
|
+
if (defaultRef && headSha(root, defaultRef) && defaultRef !== `${remote}/${branch}`) return { ref: defaultRef, from: 'remote default branch' };
|
|
110
|
+
if (upstream && headSha(root, upstream) && upstream !== `${upstreamRemote}/${branch}`) return { ref: upstream, from: 'branch upstream' };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
71
114
|
/**
|
|
72
115
|
* Resolve the reference a command should measure against, and whether it may
|
|
73
116
|
* be dropped on failure. An explicit `--changed` is the user's word: if it
|
|
74
|
-
* does not resolve, that is an error.
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* measurement, because `status` and `brief` must keep working everywhere.
|
|
117
|
+
* does not resolve, that is an error. An automatically detected reference is
|
|
118
|
+
* a convenience: if it does not resolve (shallow clone, stale local metadata,
|
|
119
|
+
* or a temp directory) the command says so on stderr and continues without a
|
|
120
|
+
* change measurement, because `status` and `brief` must keep working everywhere.
|
|
78
121
|
*/
|
|
79
|
-
export function resolveChangedRef({ explicit, env = process.env } = {}) {
|
|
122
|
+
export function resolveChangedRef({ explicit, env = process.env, projectDir } = {}) {
|
|
80
123
|
if (explicit) return { ref: explicit, from: '--changed', required: true };
|
|
81
124
|
const d = detectChangedRef(env);
|
|
82
|
-
|
|
125
|
+
if (d) return { ...d, required: false };
|
|
126
|
+
const local = detectLocalChangedRef(projectDir);
|
|
127
|
+
return local ? { ...local, required: false } : null;
|
|
83
128
|
}
|
|
84
129
|
|
|
85
130
|
/**
|
|
@@ -21,7 +21,7 @@ which files exist. `state` is one of:
|
|
|
21
21
|
| state | meaning | you do |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `no-claims` | no `testguard.claims.json` | `testguard scaffold <file>` for a file with guards; replace every `TODO:` statement with what the code guarantees; keep or drop each proposal; move the claims into `testguard.claims.json` |
|
|
24
|
-
| `unclaimed-changes` | files you changed carry no claim (
|
|
24
|
+
| `unclaimed-changes` | files you changed carry no claim (when a reference is explicit, supplied by CI, or safely inferred from local Git's remote default/upstream metadata) | `next.file` names the first; `testguard scaffold <file>` and state the claim, or add a `testguard.ignore.json` path entry with a reason a reviewer will accept. **Before** writing more code. |
|
|
25
25
|
| `invalid-anchors` | an exact fault anchor moved or became ambiguous | run `testguard claims --check-anchors`; repair `next.target` in the claims file without changing what the fault means, then re-probe. Never guess or auto-fix an anchor. |
|
|
26
26
|
| `unprobed` | claims never probed | `testguard probe` |
|
|
27
27
|
| `evidence-stale` | code, tests or claims changed since the evidence | `testguard probe --include-dirty` (or `--claim <ID>` for one) |
|
package/src/mcp/tools.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const CHANGED = { type: 'string', description: 'A git reference to measure the c
|
|
|
29
29
|
|
|
30
30
|
const statusFor = (args) => {
|
|
31
31
|
const projectDir = projectOf(args);
|
|
32
|
-
return withChangedRef(resolveChangedRef({ explicit: args?.changed }), (changedRef) =>
|
|
32
|
+
return withChangedRef(resolveChangedRef({ explicit: args?.changed, projectDir }), (changedRef) =>
|
|
33
33
|
computeStatus({ projectDir, toolVersion: args?.__version ?? '0.0.0', changedRef, evidence: evidenceOf(args) }));
|
|
34
34
|
};
|
|
35
35
|
|