testguard-cli 0.6.0 → 0.7.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/CHANGELOG.md +373 -0
- package/README.md +277 -27
- package/package.json +2 -2
- package/spec/GATE-SEMANTICS.md +112 -5
- package/spec/README.md +28 -10
- package/spec/lib/validate.mjs +84 -8
- package/spec/schemas/baseline.schema.json +7 -7
- package/spec/schemas/brief.schema.json +12 -12
- package/spec/schemas/calibration.schema.json +5 -5
- package/spec/schemas/claims.schema.json +122 -25
- package/spec/schemas/common.schema.json +127 -29
- package/spec/schemas/evidence.schema.json +169 -73
- package/spec/schemas/gate.schema.json +12 -12
- package/spec/schemas/ignore.schema.json +4 -4
- package/spec/schemas/replay.schema.json +10 -10
- package/spec/schemas/status.schema.json +25 -25
- package/src/cli.mjs +20 -5
- package/src/commands/claims.mjs +22 -2
- package/src/commands/probe.mjs +24 -4
- package/src/gate/changed.mjs +9 -2
- package/src/probe/attribution.mjs +123 -0
- package/src/probe/classify.mjs +18 -2
- package/src/probe/cost.mjs +145 -0
- package/src/probe/discover.mjs +14 -6
- package/src/probe/inject.mjs +41 -9
- package/src/probe/mocks.mjs +2 -0
- package/src/probe/probe.mjs +235 -52
- package/src/probe/progress.mjs +87 -0
- package/src/probe/pyimports.mjs +287 -0
- package/src/probe/rank.mjs +2 -0
- package/src/probe/runners/index.mjs +38 -11
- package/src/probe/runners/jest.mjs +13 -1
- package/src/probe/runners/playwright.mjs +9 -0
- package/src/probe/runners/python/_testguard_pytest_plugin.py +132 -0
- package/src/probe/runners/python/_testguard_report.py +141 -0
- package/src/probe/runners/python/_testguard_unittest_main.py +168 -0
- package/src/probe/runners/python.mjs +316 -0
- package/src/probe/runners/shared.mjs +10 -1
- package/src/probe/runners/vitest.mjs +13 -1
- package/src/replay/replay.mjs +3 -2
- package/src/scaffold/producers.python.mjs +209 -0
- package/src/scaffold/scaffold.mjs +86 -32
- package/src/util/glob.mjs +7 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$id": "urn:
|
|
4
|
-
"title": "
|
|
3
|
+
"$id": "urn:claimspec:v1:status",
|
|
4
|
+
"title": "claimspec v1 \u2014 status",
|
|
5
5
|
"description": "The single machine-readable answer to 'where is this project and what should happen next'. Every human rendering (CLI text, agent brief, skill instructions) derives from this document so they cannot disagree. Computed from the claims file, the evidence, the baseline and the current working tree; never from a cached verdict alone.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
"const": 1
|
|
21
21
|
},
|
|
22
22
|
"tool": {
|
|
23
|
-
"$ref": "urn:
|
|
23
|
+
"$ref": "urn:claimspec:v1:common#/$defs/tool"
|
|
24
24
|
},
|
|
25
25
|
"generatedAt": {
|
|
26
|
-
"$ref": "urn:
|
|
26
|
+
"$ref": "urn:claimspec:v1:common#/$defs/isoDateTime"
|
|
27
27
|
},
|
|
28
28
|
"state": {
|
|
29
29
|
"description": "no-claims: no claims file. unclaimed-changes: the change measured against a reference touches source files that carry no claim and no excusing ignore entry (only computed when a reference is known; it precedes every evidence state because the claim is written before more code). unprobed: claims but no confirmed evidence. evidence-stale: evidence exists but a claim, fault, target or defender changed since. provisional-only: only sub-N evidence exists. unproven: current evidence has findings not covered by the baseline. clean: every finding is killed or baselined.",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
"file": {
|
|
71
71
|
"description": "For claim (and for scaffold when a change is known): the first uncovered changed file the action is about.",
|
|
72
|
-
"$ref": "urn:
|
|
72
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
73
73
|
},
|
|
74
74
|
"target": {
|
|
75
75
|
"description": "For write-test / review-fault-change: the finding the action is about.",
|
|
@@ -80,16 +80,16 @@
|
|
|
80
80
|
],
|
|
81
81
|
"properties": {
|
|
82
82
|
"claimId": {
|
|
83
|
-
"$ref": "urn:
|
|
83
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
84
84
|
},
|
|
85
85
|
"subjectId": {
|
|
86
|
-
"$ref": "urn:
|
|
86
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
87
87
|
},
|
|
88
88
|
"file": {
|
|
89
|
-
"$ref": "urn:
|
|
89
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
90
90
|
},
|
|
91
91
|
"verdict": {
|
|
92
|
-
"$ref": "urn:
|
|
92
|
+
"$ref": "urn:claimspec:v1:common#/$defs/verdict"
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
95
|
"additionalProperties": false
|
|
@@ -173,16 +173,16 @@
|
|
|
173
173
|
],
|
|
174
174
|
"properties": {
|
|
175
175
|
"claimId": {
|
|
176
|
-
"$ref": "urn:
|
|
176
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
177
177
|
},
|
|
178
178
|
"subjectId": {
|
|
179
|
-
"$ref": "urn:
|
|
179
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
180
180
|
},
|
|
181
181
|
"previousVerdict": {
|
|
182
|
-
"$ref": "urn:
|
|
182
|
+
"$ref": "urn:claimspec:v1:common#/$defs/verdict"
|
|
183
183
|
},
|
|
184
184
|
"file": {
|
|
185
|
-
"$ref": "urn:
|
|
185
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
186
186
|
}
|
|
187
187
|
},
|
|
188
188
|
"additionalProperties": false
|
|
@@ -204,26 +204,26 @@
|
|
|
204
204
|
],
|
|
205
205
|
"properties": {
|
|
206
206
|
"fingerprint": {
|
|
207
|
-
"$ref": "urn:
|
|
207
|
+
"$ref": "urn:claimspec:v1:common#/$defs/sha256"
|
|
208
208
|
},
|
|
209
209
|
"claimId": {
|
|
210
|
-
"$ref": "urn:
|
|
210
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
211
211
|
},
|
|
212
212
|
"subjectId": {
|
|
213
|
-
"$ref": "urn:
|
|
213
|
+
"$ref": "urn:claimspec:v1:common#/$defs/identifier"
|
|
214
214
|
},
|
|
215
215
|
"verdict": {
|
|
216
|
-
"$ref": "urn:
|
|
216
|
+
"$ref": "urn:claimspec:v1:common#/$defs/verdict"
|
|
217
217
|
},
|
|
218
218
|
"reason": {
|
|
219
219
|
"type": "string",
|
|
220
220
|
"maxLength": 256
|
|
221
221
|
},
|
|
222
222
|
"severity": {
|
|
223
|
-
"$ref": "urn:
|
|
223
|
+
"$ref": "urn:claimspec:v1:common#/$defs/severity"
|
|
224
224
|
},
|
|
225
225
|
"file": {
|
|
226
|
-
"$ref": "urn:
|
|
226
|
+
"$ref": "urn:claimspec:v1:common#/$defs/repoPath"
|
|
227
227
|
},
|
|
228
228
|
"rank": {
|
|
229
229
|
"type": "number"
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
"maxLength": 256
|
|
256
256
|
},
|
|
257
257
|
"base": {
|
|
258
|
-
"$ref": "urn:
|
|
258
|
+
"$ref": "urn:claimspec:v1:common#/$defs/gitSha"
|
|
259
259
|
},
|
|
260
260
|
"includeDirty": {
|
|
261
261
|
"type": "boolean"
|
|
@@ -275,19 +275,19 @@
|
|
|
275
275
|
"uncovered": {
|
|
276
276
|
"type": "array",
|
|
277
277
|
"items": {
|
|
278
|
-
"$ref": "urn:
|
|
278
|
+
"$ref": "urn:claimspec:v1:common#/$defs/uncoveredFile"
|
|
279
279
|
}
|
|
280
280
|
},
|
|
281
281
|
"reliedOn": {
|
|
282
282
|
"type": "array",
|
|
283
283
|
"items": {
|
|
284
|
-
"$ref": "urn:
|
|
284
|
+
"$ref": "urn:claimspec:v1:common#/$defs/ignoreReliance"
|
|
285
285
|
}
|
|
286
286
|
},
|
|
287
287
|
"expired": {
|
|
288
288
|
"type": "array",
|
|
289
289
|
"items": {
|
|
290
|
-
"$ref": "urn:
|
|
290
|
+
"$ref": "urn:claimspec:v1:common#/$defs/ignoreReliance"
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
},
|
|
@@ -323,11 +323,11 @@
|
|
|
323
323
|
},
|
|
324
324
|
"evidenceHead": {
|
|
325
325
|
"description": "The commit the evidence describes (its snapshot when the working tree was probed). Present whenever evidence was read.",
|
|
326
|
-
"$ref": "urn:
|
|
326
|
+
"$ref": "urn:claimspec:v1:common#/$defs/gitSha"
|
|
327
327
|
},
|
|
328
328
|
"head": {
|
|
329
329
|
"description": "HEAD of the working tree the status was computed in, when resolvable. Differs from evidenceHead when the evidence came from elsewhere.",
|
|
330
|
-
"$ref": "urn:
|
|
330
|
+
"$ref": "urn:claimspec:v1:common#/$defs/gitSha"
|
|
331
331
|
}
|
|
332
332
|
},
|
|
333
333
|
"additionalProperties": false
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { join, resolve } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { ClaimsError } from './claims/load.mjs';
|
|
6
6
|
import { PreconditionError } from './probe/worktree.mjs';
|
|
7
|
+
import { RUNNER_NAMES } from './probe/runners/index.mjs';
|
|
7
8
|
import { GitError } from './git.mjs';
|
|
8
9
|
import { SpecDocError } from './evidence/writer.mjs';
|
|
9
10
|
import { probeCommand } from './commands/probe.mjs';
|
|
@@ -48,12 +49,22 @@ probe
|
|
|
48
49
|
--include-dirty probe the working tree (a snapshot commit) instead of HEAD; uncommitted tests count
|
|
49
50
|
(without it, a dirty defender/target with the implicit HEAD is refused: the silent-mismatch trap)
|
|
50
51
|
--verbose also print each killed fault (default: only unproven ones, plus a count)
|
|
52
|
+
--cost (probe, claims) report what the defenders cost, per claim and per defender file
|
|
53
|
+
--progress <mode> auto|tty|plain|ndjson|none. auto rewrites one line at a terminal and prints
|
|
54
|
+
append-only lines everywhere else, so a redirected log and CI see progress
|
|
55
|
+
instead of twenty silent minutes. Always on stderr; ndjson also streams verdicts
|
|
51
56
|
--confirm below 3 is PROVISIONAL: verdicts print with "?", evidence goes to .testguard/evidence-provisional.json
|
|
52
|
-
--runner <name> vitest | jest | playwright |
|
|
53
|
-
|
|
57
|
+
--runner <name> vitest | jest | playwright | python | pytest | unittest | auto
|
|
58
|
+
(default: auto — first of vitest, jest, python that resolves. A defender under playwright's testDir
|
|
59
|
+
always runs under playwright, and a .py defender always under python, whatever the project runner.
|
|
60
|
+
python picks pytest when the interpreter can import it and stdlib unittest otherwise; pytest and
|
|
61
|
+
unittest pin that choice and fail rather than fall back. The engine that ran is what the evidence records.)
|
|
54
62
|
--runner-cmd "<cmd>" custom runner; must contain {files} and {out}, e.g. "pnpm vitest run {files} --reporter=json --outputFile={out}"
|
|
55
63
|
--node-modules <dir> node_modules to link into the scratch worktree (or TESTGUARD_NODE_MODULES)
|
|
56
|
-
--
|
|
64
|
+
--python <path> Python interpreter for .py defenders (or TESTGUARD_PYTHON; default: $VIRTUAL_ENV, then the project's
|
|
65
|
+
.venv/venv, then python3 on PATH). Resolved against your working tree, never the scratch worktree.
|
|
66
|
+
--serial run one test file at a time (vitest --no-file-parallelism, jest --runInBand, playwright --workers=1,
|
|
67
|
+
pytest -p no:xdist);
|
|
57
68
|
use it when another test runner is already running — probe warns and records the contention either way
|
|
58
69
|
--in-place mutate the working tree instead of a scratch worktree
|
|
59
70
|
--no-escalate do not re-run survivors against the whole suite
|
|
@@ -93,6 +104,7 @@ init --force (replace an existing skill file) --here (keep the agent laye
|
|
|
93
104
|
--mcp print the MCP server config for Claude Code, Cursor and Codex (printed, never written: a harness config is yours)
|
|
94
105
|
every command accepts --json; probe/baseline emit the status document plus their own result
|
|
95
106
|
claims --json
|
|
107
|
+
--cost what the last probe spent, per claim and per defender file, read back from the recorded run durations. Names the files more than one claim pays for, which is where a slow gate comes from
|
|
96
108
|
--since <ref> report every claim and fault that existed at <ref> and does not now; a claim entry in testguard.ignore.json (with a reason) excuses one. exit 1 on any unexcused removal
|
|
97
109
|
baseline --evidence <path> --out <path> --allow-provisional (freeze unconfirmed evidence; normally refused)
|
|
98
110
|
--restamp move head to the commit of a later CLEAN probe that reproduced the same fingerprints
|
|
@@ -139,6 +151,7 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
139
151
|
here: { type: 'boolean', default: false },
|
|
140
152
|
verbose: { type: 'boolean', default: false },
|
|
141
153
|
'runner-cmd': { type: 'string' },
|
|
154
|
+
python: { type: 'string' },
|
|
142
155
|
runner: { type: 'string', default: 'auto' },
|
|
143
156
|
'node-modules': { type: 'string' },
|
|
144
157
|
'in-place': { type: 'boolean', default: false },
|
|
@@ -148,6 +161,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
148
161
|
json: { type: 'boolean', default: false },
|
|
149
162
|
text: { type: 'boolean', default: false },
|
|
150
163
|
markdown: { type: 'boolean', default: false },
|
|
164
|
+
cost: { type: 'boolean', default: false },
|
|
165
|
+
progress: { type: 'string' },
|
|
151
166
|
help: { type: 'boolean', short: 'h', default: false },
|
|
152
167
|
version: { type: 'boolean', short: 'v', default: false },
|
|
153
168
|
},
|
|
@@ -173,8 +188,8 @@ export async function main(argv, io = { out: (s) => process.stdout.write(s + '\n
|
|
|
173
188
|
io.err(USAGE);
|
|
174
189
|
return 3;
|
|
175
190
|
}
|
|
176
|
-
if (![
|
|
177
|
-
io.err(
|
|
191
|
+
if (![...RUNNER_NAMES, 'auto'].includes(values.runner)) {
|
|
192
|
+
io.err(`--runner must be one of ${RUNNER_NAMES.join(', ')} or auto`);
|
|
178
193
|
return 3;
|
|
179
194
|
}
|
|
180
195
|
if (!['critical', 'high', 'medium', 'low'].includes(values.severity)) {
|
package/src/commands/claims.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import { discoverDefendersDetailed } from '../probe/discover.mjs';
|
|
|
6
6
|
import { classifyDefenders } from '../probe/mocks.mjs';
|
|
7
7
|
import { computeRemovedClaims, renderRemoved } from '../claims/removed.mjs';
|
|
8
8
|
import { evidencePath } from './probe.mjs';
|
|
9
|
+
import { costReport, renderCost } from '../probe/cost.mjs';
|
|
10
|
+
import { readSpecDoc } from '../evidence/writer.mjs';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
9
12
|
|
|
10
13
|
export async function claimsCommand({ projectDir, values, version }, io) {
|
|
11
14
|
const path = values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir);
|
|
@@ -19,8 +22,16 @@ export async function claimsCommand({ projectDir, values, version }, io) {
|
|
|
19
22
|
? computeRemovedClaims({ projectDir, ref: values.since, claimsPath: path, current: claims, ignorePath: values.ignore ? resolve(values.ignore) : undefined, evidencePath: evidencePath(projectDir), toolVersion: version })
|
|
20
23
|
: undefined;
|
|
21
24
|
|
|
25
|
+
// Cost is read back out of evidence the probe already wrote; it never runs a test.
|
|
26
|
+
let cost;
|
|
27
|
+
if (values.cost) {
|
|
28
|
+
const ev = values.evidence ? resolve(values.evidence) : evidencePath(projectDir);
|
|
29
|
+
cost = existsSync(ev) ? costReport(readSpecDoc('evidence', ev).records) : undefined;
|
|
30
|
+
if (!cost && !values.json) io.err(`no evidence at ${ev} — run \`testguard probe\` first; cost is derived from the run durations it records`);
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
if (values.json) {
|
|
23
|
-
io.out(JSON.stringify({ path, claims, annotations, drift, ...(removed ? { removed } : {}) }, null, 2));
|
|
34
|
+
io.out(JSON.stringify({ path, claims, annotations, drift, ...(removed ? { removed } : {}), ...(cost ? { cost } : {}) }, null, 2));
|
|
24
35
|
} else {
|
|
25
36
|
const annotated = new Set(drift.annotated);
|
|
26
37
|
io.out(`${claims.claims.length} claims in ${path} — ${annotated.size} carry a @claim annotation in source (test files are not scanned)`);
|
|
@@ -44,7 +55,12 @@ export async function claimsCommand({ projectDir, values, version }, io) {
|
|
|
44
55
|
}
|
|
45
56
|
if (signalLines.length) io.out('');
|
|
46
57
|
for (const s of signalLines) {
|
|
47
|
-
|
|
58
|
+
// The annotation's comment marker is the probed file's, not JavaScript's.
|
|
59
|
+
const marker = s.file.endsWith('.py') ? '#' : '//';
|
|
60
|
+
if (s.signal === 'mocked-never-asserted') io.out(`MOCKED-NEVER-ASSERTED ${s.file} mocks ${s.target} and never asserts on it (${s.claim}) — assert on the mocked call, drive the real module, or annotate the mock \`${marker} unasserted: <why>\``);
|
|
61
|
+
// An attribute patch does not remove a defender; saying it "mocks" the
|
|
62
|
+
// module would tell the author the opposite of what happened.
|
|
63
|
+
else if (s.signal === 'target-attribute-patched') io.out(`attribute-patched ${s.file} defends ${s.target} but replaces ${s.reason} (${s.claim}) — a fault in those attributes is where it is least likely to notice`);
|
|
48
64
|
else io.out(`unasserted (annotated) ${s.file} mocks ${s.target}: ${s.reason} (${s.claim})`);
|
|
49
65
|
}
|
|
50
66
|
if (drift.undeclared.length || drift.stale.length) io.out('');
|
|
@@ -54,6 +70,10 @@ export async function claimsCommand({ projectDir, values, version }, io) {
|
|
|
54
70
|
io.out('');
|
|
55
71
|
io.out(renderRemoved(removed));
|
|
56
72
|
}
|
|
73
|
+
if (cost) {
|
|
74
|
+
io.out('');
|
|
75
|
+
io.out(renderCost(cost));
|
|
76
|
+
}
|
|
57
77
|
}
|
|
58
78
|
return drift.undeclared.length || drift.stale.length || (removed?.removed.length ?? 0) > 0 ? 1 : 0;
|
|
59
79
|
}
|
package/src/commands/probe.mjs
CHANGED
|
@@ -7,6 +7,8 @@ import { gate } from '../baseline/baseline.mjs';
|
|
|
7
7
|
import { renderRecord, renderSummary, sortForReport, PROVISIONAL_WARNING } from '../render.mjs';
|
|
8
8
|
import { computeStatus } from '../status/status.mjs';
|
|
9
9
|
import { resolveChangedRef, withChangedRef } from '../gate/changed.mjs';
|
|
10
|
+
import { costReport, renderCost } from '../probe/cost.mjs';
|
|
11
|
+
import { progressMode, stageReporter, clearStageLine, recordEvent, isProgressMode, progressStream } from '../probe/progress.mjs';
|
|
10
12
|
export const provisionalEvidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence-provisional.json');
|
|
11
13
|
|
|
12
14
|
export const evidencePath = (projectDir) => join(projectDir, '.testguard', 'evidence.json');
|
|
@@ -19,6 +21,14 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
19
21
|
io.err('--confirm must be a positive integer and --budget at least 1000');
|
|
20
22
|
return 3;
|
|
21
23
|
}
|
|
24
|
+
if (values.progress && !isProgressMode(values.progress)) {
|
|
25
|
+
io.err(`--progress must be one of auto, tty, plain, ndjson, none (got ${values.progress})`);
|
|
26
|
+
return 3;
|
|
27
|
+
}
|
|
28
|
+
// Progress always goes to stderr, so --json keeps stdout parseable.
|
|
29
|
+
const progress = progressMode({ explicit: values.progress, isTTY: process.stderr.isTTY, quiet: values.quiet, json: values.json });
|
|
30
|
+
const progressTo = progressStream(process);
|
|
31
|
+
const stageOut = stageReporter(progress, (s) => progressTo.write(s));
|
|
22
32
|
const claims = loadClaims(values.claims ? resolve(values.claims) : defaultClaimsPath(projectDir));
|
|
23
33
|
if (claims.claims.length === 0) {
|
|
24
34
|
io.err('claims file declares no claims; nothing to verify');
|
|
@@ -50,13 +60,18 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
50
60
|
runnerCommand: values['runner-cmd'],
|
|
51
61
|
runnerName: values.runner,
|
|
52
62
|
nodeModules: values['node-modules'] ? resolve(values['node-modules']) : process.env.TESTGUARD_NODE_MODULES,
|
|
63
|
+
python: values.python ? resolve(values.python) : undefined,
|
|
53
64
|
only,
|
|
54
65
|
escalate: !values['no-escalate'],
|
|
55
66
|
toolVersion: version,
|
|
56
67
|
includeDirty: values['include-dirty'],
|
|
57
|
-
onStage:
|
|
58
|
-
onProgress:
|
|
59
|
-
|
|
68
|
+
onStage: stageOut,
|
|
69
|
+
onProgress: (r) => {
|
|
70
|
+
// ndjson streams verdicts as they land; a watcher sees them without
|
|
71
|
+
// waiting for the document at the end.
|
|
72
|
+
if (progress === 'ndjson') progressTo.write(recordEvent(r));
|
|
73
|
+
if (values.quiet || values.json) return;
|
|
74
|
+
progressTo.write(clearStageLine(progress));
|
|
60
75
|
// The discovery note is a property of the claim, not of each fault: say it once.
|
|
61
76
|
const firstOfClaim = !discoveryNoted.has(r.claim.id);
|
|
62
77
|
if (r.defenders.discovered) discoveryNoted.add(r.claim.id);
|
|
@@ -68,7 +83,7 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
68
83
|
const g = gate(evidence.records, baseline, { severityFloor: values.severity });
|
|
69
84
|
if (values.json) {
|
|
70
85
|
const status = withChangedRef(resolveChangedRef({ explicit: values.changed }), (changedRef) => computeStatus({ projectDir, toolVersion: version, changedRef, includeDirty: values['include-dirty'], evidence: outPath }), io.err);
|
|
71
|
-
io.out(JSON.stringify({ ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0 } }, null, 2));
|
|
86
|
+
io.out(JSON.stringify({ ...status, run: { id: evidence.run.id, evidence: outPath, provisional, records: evidence.records.length, newSinceBaseline: g.new.length, exitCode: g.new.length > 0 ? 1 : 0 }, ...(values.cost ? { cost: costReport(evidence.records) } : {}) }, null, 2));
|
|
72
87
|
return g.new.length > 0 ? 1 : 0;
|
|
73
88
|
}
|
|
74
89
|
if (!values.quiet && baseline) {
|
|
@@ -82,6 +97,11 @@ export async function probeCommand({ projectDir, values, version }, io) {
|
|
|
82
97
|
if (killed) io.out(` ${killed} killed (not listed; --verbose to see them)`);
|
|
83
98
|
}
|
|
84
99
|
io.out(renderSummary(evidence.records, evidence.run) + (baseline ? ` ${g.new.length} new since baseline, ${g.baselined.length} baselined.` : ' No baseline.'));
|
|
100
|
+
if (values.cost) {
|
|
101
|
+
io.out('');
|
|
102
|
+
io.out(renderCost(costReport(evidence.records)));
|
|
103
|
+
io.out('');
|
|
104
|
+
}
|
|
85
105
|
io.out(`evidence: ${outPath}${only ? ` (partial: --claim ${only.join(',')}; not the canonical evidence file)` : provisional ? ' (provisional; not the canonical evidence file)' : ''}`);
|
|
86
106
|
if (provisional && !values.quiet) io.err(PROVISIONAL_WARNING(confirmRuns));
|
|
87
107
|
return g.new.length > 0 ? 1 : 0;
|
package/src/gate/changed.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { loadClaims, defaultClaimsPath } from '../claims/load.mjs';
|
|
|
5
5
|
import { readSpecDoc } from '../evidence/writer.mjs';
|
|
6
6
|
import { resolveDefenders } from '../probe/runners/shared.mjs';
|
|
7
7
|
import { discoverDefenders } from '../probe/discover.mjs';
|
|
8
|
+
import { IS_PY_TEST } from '../probe/pyimports.mjs';
|
|
8
9
|
import { globToRegExp } from '../util/glob.mjs';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -20,8 +21,14 @@ import { globToRegExp } from '../util/glob.mjs';
|
|
|
20
21
|
* the smallest unit the rest of the contract already understands.
|
|
21
22
|
*/
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// `.py` belongs here for the same reason every other extension does: a file
|
|
25
|
+
// the gate does not recognise as source is excluded as `non-source`, which
|
|
26
|
+
// means a changed Python file could never be reported as uncovered and the
|
|
27
|
+
// gate would pass while saying nothing.
|
|
28
|
+
export const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
|
|
24
29
|
const TEST_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
30
|
+
// Python names tests by file, not by a dotted suffix: `test_foo.py`, `foo_test.py`.
|
|
31
|
+
const isTestFile = (f) => TEST_RE.test(f) || IS_PY_TEST.test(f);
|
|
25
32
|
|
|
26
33
|
/** Files that are source by extension but never carry claims. Printed by --explain. */
|
|
27
34
|
export const DEFAULT_EXCLUDES = Object.freeze([
|
|
@@ -163,7 +170,7 @@ export function computeChangedGate({ projectDir, ref, includeDirty = false, excl
|
|
|
163
170
|
const dx = defaultExcludes.find((x) => x.re.test(file));
|
|
164
171
|
if (dx) { excluded.push({ file, by: `default:${dx.glob}` }); continue; }
|
|
165
172
|
|
|
166
|
-
const isTest =
|
|
173
|
+
const isTest = isTestFile(file);
|
|
167
174
|
if (faultFiles.has(file)) { covered.push({ file, by: 'fault', claimIds: faultFiles.get(file) }); continue; }
|
|
168
175
|
if (isTest && defenderFiles.has(file)) { covered.push({ file, by: 'defender', claimIds: defenderFiles.get(file) }); continue; }
|
|
169
176
|
const active = pathEntries.find((p) => !p.expired && p.re.test(file));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decisions escalation and flake measurement make, as pure functions.
|
|
3
|
+
*
|
|
4
|
+
* These used to live inline in `probeOne`, which meant the only thing that
|
|
5
|
+
* could falsify them was a full fixture probe — a 50-second acceptance test
|
|
6
|
+
* standing in for a rule you can state in three lines. `classify()` has always
|
|
7
|
+
* been pure for this reason: the order of its checks *is* the spec, and every
|
|
8
|
+
* branch is unit-testable without a runner. Same principle here.
|
|
9
|
+
*
|
|
10
|
+
* Nothing in this file runs a test, touches the filesystem, or knows what a
|
|
11
|
+
* worktree is. Everything it needs is passed in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const isKill = (r) => r.outcome === 'fail' && (r.assertionFailures ?? 0) > 0;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fold one escalation run into the running attribution state.
|
|
18
|
+
*
|
|
19
|
+
* A test is an undeclared killer only if it fails in **every** run: a single
|
|
20
|
+
* run cannot say, because a test that is flaky elsewhere in the suite would
|
|
21
|
+
* take the credit. So the killer set is an intersection, seeded by the first
|
|
22
|
+
* run rather than by the empty set.
|
|
23
|
+
*
|
|
24
|
+
* Once the intersection is empty no further run can name a killer, so the
|
|
25
|
+
* caller may stop early. Stopping can only fail to *name* a killer, never
|
|
26
|
+
* upgrade a verdict, which is the pessimistic side and therefore the safe one.
|
|
27
|
+
*/
|
|
28
|
+
export function foldEscalationRun(state, { run, failedTests }) {
|
|
29
|
+
const runs = [...state.runs, run];
|
|
30
|
+
const killers = state.killers === null ? new Set(failedTests) : new Set([...state.killers].filter((t) => failedTests.includes(t)));
|
|
31
|
+
return { runs, killers, stoppedEarly: killers.size === 0 ? 'no-common-failure' : undefined };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The initial attribution state, before any escalation run. */
|
|
35
|
+
export const escalationStart = () => ({ runs: [], killers: null, stoppedEarly: undefined });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What escalation concluded. `undeclaredKillers` is named only when a test
|
|
39
|
+
* failed in all N runs AND every run was a genuine kill (an assertion
|
|
40
|
+
* failure). A suite that errored or timed out proves nothing about the fault,
|
|
41
|
+
* so it never earns an attribution.
|
|
42
|
+
*/
|
|
43
|
+
export function escalationResult(state, confirmRuns) {
|
|
44
|
+
const complete = state.runs.length === confirmRuns;
|
|
45
|
+
const attributable = state.killers !== null && state.killers.size > 0 && complete && state.runs.every(isKill);
|
|
46
|
+
return {
|
|
47
|
+
escalated: true,
|
|
48
|
+
escalationRuns: state.runs,
|
|
49
|
+
...(state.stoppedEarly ? { escalationStoppedEarly: state.stoppedEarly } : {}),
|
|
50
|
+
...(attributable ? { reason: 'killed-by-undeclared-tests', undeclaredKillers: [...state.killers].sort() } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The defenders' flake rate on unmodified source.
|
|
56
|
+
*
|
|
57
|
+
* Only runs that **actually ran** carry information. A load error or a timeout
|
|
58
|
+
* measures the environment, not the suite's stability, and counting either as
|
|
59
|
+
* a "failure" would invent flakiness that was never observed. Returns
|
|
60
|
+
* `undefined` when nothing was measurable, so the caller omits the field
|
|
61
|
+
* rather than recording a rate of zero it cannot support.
|
|
62
|
+
*/
|
|
63
|
+
export function flakeRate(runs) {
|
|
64
|
+
const measured = runs.filter((r) => r.outcome === 'pass' || r.outcome === 'fail');
|
|
65
|
+
if (!measured.length) return undefined;
|
|
66
|
+
return { runs: measured.length, failures: measured.filter((r) => r.outcome === 'fail').length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The tests that actually failed on the fault, by file. Only these are its
|
|
71
|
+
* killers; everything else in a run is noise for attribution purposes.
|
|
72
|
+
*/
|
|
73
|
+
export function killersFromRuns(probeRuns) {
|
|
74
|
+
return [...new Set(probeRuns.flatMap((r) => r.failedTests ?? []).map((t) => t.split('::')[0]).filter(Boolean))];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `subject` of an evidence record. `contentHash` is what makes a fault edit
|
|
79
|
+
* visible: weakening a fault after it survived changes this hash, and `status`
|
|
80
|
+
* reports the change with the verdict the fault used to have. It is deliberately
|
|
81
|
+
* NOT part of the fingerprint — repairing a rotted anchor must not churn the
|
|
82
|
+
* baseline — which is exactly why it has to be recorded separately.
|
|
83
|
+
*/
|
|
84
|
+
export function subjectOf(fault, sha256) {
|
|
85
|
+
return {
|
|
86
|
+
kind: 'fault',
|
|
87
|
+
id: fault.id,
|
|
88
|
+
description: fault.description,
|
|
89
|
+
file: fault.file,
|
|
90
|
+
faultClass: fault.faultClass,
|
|
91
|
+
producedBy: fault.producedBy,
|
|
92
|
+
contentHash: sha256(`${fault.find}\n${fault.replace}`),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* May a prior record stand in for this one, or must the fault be probed again?
|
|
98
|
+
*
|
|
99
|
+
* Same source, same defenders, same N — and the SAME FAULT. The last is not a
|
|
100
|
+
* refinement: a verdict is an answer about one specific `find`/`replace`, and
|
|
101
|
+
* once those change the old answer is not an answer to the new question.
|
|
102
|
+
* Without it, weakening a fault that survived keeps its old verdict, and
|
|
103
|
+
* repairing a rotted anchor keeps `unverifiable` — the evidence reporting a
|
|
104
|
+
* result that was never measured against the fault the file now contains.
|
|
105
|
+
* `status` flags the changed hash afterwards, but by then the evidence has
|
|
106
|
+
* already said something untrue.
|
|
107
|
+
*
|
|
108
|
+
* A prior that predates `contentHash` cannot be compared, so it is re-probed.
|
|
109
|
+
* The cost of re-measuring is a run; the cost of the other direction is a
|
|
110
|
+
* verdict nobody measured.
|
|
111
|
+
*/
|
|
112
|
+
export function isReusable(prior, current) {
|
|
113
|
+
if (!prior || !prior.subject?.contentHash) return false;
|
|
114
|
+
// A record that exists because the probe threw is a statement about the run,
|
|
115
|
+
// not about the code. Reusing it would make one disturbed run permanent.
|
|
116
|
+
if (prior.detail?.reason === 'probe-error') return false;
|
|
117
|
+
const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
|
|
118
|
+
return prior.inputs.targetHash === current.inputs.targetHash
|
|
119
|
+
&& same(prior.inputs.defenderHashes, current.inputs.defenderHashes)
|
|
120
|
+
&& same(prior.defenders.requested, current.requested)
|
|
121
|
+
&& same(prior.defenders.resolved, current.resolved)
|
|
122
|
+
&& prior.subject.contentHash === current.contentHash;
|
|
123
|
+
}
|
package/src/probe/classify.mjs
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* encodes GATE-SEMANTICS.md: nothing to run → nocover; nothing to apply →
|
|
7
7
|
* unverifiable; defenders not green → flaky-defender; then, and only then,
|
|
8
8
|
* does the fault's own result count.
|
|
9
|
+
*
|
|
10
|
+
* `subjectReached` is the negative control's answer and is consulted at one
|
|
11
|
+
* point only: a would-be `survived`. It is never asked about a kill — a kill
|
|
12
|
+
* already proves the defenders reached the code — and `undefined` (the control
|
|
13
|
+
* was not run) never changes a verdict.
|
|
9
14
|
*/
|
|
10
|
-
export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns }) {
|
|
15
|
+
export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRuns, subjectReached }) {
|
|
11
16
|
if (defenders.length === 0) return { verdict: 'nocover' };
|
|
12
17
|
if (anchor && anchor.status !== 'ok') return { verdict: 'unverifiable', reason: anchor.status };
|
|
13
18
|
if (baselineRuns.length === 0 || baselineRuns.some((r) => r.outcome !== 'pass')) {
|
|
@@ -24,7 +29,18 @@ export function classify({ defenders, anchor, baselineRuns, probeRuns, confirmRu
|
|
|
24
29
|
const killedRuns = probeRuns.filter((r) => r.outcome === 'fail' && r.assertionFailures > 0);
|
|
25
30
|
const passedRuns = probeRuns.filter((r) => r.outcome === 'pass');
|
|
26
31
|
if (probeRuns.length === confirmRuns && killedRuns.length === confirmRuns) return { verdict: 'killed' };
|
|
27
|
-
if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns)
|
|
32
|
+
if (probeRuns.length === confirmRuns && passedRuns.length === confirmRuns) {
|
|
33
|
+
// The negative control. A green baseline proves the defenders can PASS; it
|
|
34
|
+
// says nothing about whether they can fail because of THIS file. If the
|
|
35
|
+
// subject was replaced with something that cannot compile and the
|
|
36
|
+
// defenders still went green, they never execute it — and then `survived`
|
|
37
|
+
// is a statement about their reach, not their assertions, and reads as a
|
|
38
|
+
// devastating audit finding while being entirely false.
|
|
39
|
+
// `undefined` means the control was not run, which is not evidence either
|
|
40
|
+
// way and leaves the verdict alone.
|
|
41
|
+
if (subjectReached === false) return { verdict: 'unverifiable', reason: 'subject-not-executed' };
|
|
42
|
+
return { verdict: 'survived' };
|
|
43
|
+
}
|
|
28
44
|
// Some runs killed, some passed: the defenders' response to this fault is
|
|
29
45
|
// nondeterministic. Reporting it as killed would be the optimistic bias
|
|
30
46
|
// the research warned about; reporting survived would be a lie the other way.
|