staysfixed 0.14.0 → 0.15.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 +32 -0
- package/package.json +1 -1
- package/src/guard/api.js +43 -0
- package/src/guard/run.js +34 -4
- package/src/report/console.js +23 -1
- package/src/types.js +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,38 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.0] — 2026-09-02
|
|
10
|
+
|
|
11
|
+
### A guard can say it cannot be answered here
|
|
12
|
+
|
|
13
|
+
Fifty guards were written for a real product in one night and put through a real run. Several
|
|
14
|
+
failed on that machine for reasons that had nothing to do with the product: one wanted a paired
|
|
15
|
+
second machine, one an Android phone, several a session in a state nothing had put it in.
|
|
16
|
+
Nothing was broken. They were being asked somewhere that could not answer them.
|
|
17
|
+
|
|
18
|
+
Until now such a guard had two ways out and both were wrong. **Passing is a lie** — it reports
|
|
19
|
+
that a bug did not come back, having looked for nothing, which is the false all-clear this whole
|
|
20
|
+
tool exists to prevent, wearing the friendliest face it has. **Failing is a false alarm**, and
|
|
21
|
+
worse than it sounds: the line printed over a failed guard is "bugs that were already fixed are
|
|
22
|
+
back", so somebody hunts a regression that never happened, and after twice they stop believing
|
|
23
|
+
any of them.
|
|
24
|
+
|
|
25
|
+
- `cannotRunHere(why)` on a guard's run context stops it and reports **not proved**, with the
|
|
26
|
+
reason and what would let it run. Counted as neither a pass nor a failure, and excluded from
|
|
27
|
+
what the run says it looked at.
|
|
28
|
+
- It **cannot** be used after an expectation has already failed. A door out of a red run is a
|
|
29
|
+
door somebody would eventually walk through.
|
|
30
|
+
- It is for what the MACHINE is missing, never for what the product is doing — using it because
|
|
31
|
+
the product looks wrong would turn a real finding into silence.
|
|
32
|
+
- `not proved` is its own verdict everywhere it is printed, distinct from `left out on purpose`,
|
|
33
|
+
which means somebody deliberately switched a guard off. The story of the original bug is
|
|
34
|
+
deliberately not printed underneath it: under a guard that never asked its question, "why this
|
|
35
|
+
guard exists" reads as that bug being back.
|
|
36
|
+
|
|
37
|
+
On the run that prompted it, 56 guards came back 40 held, 11 not proved, 5 failed — and all five
|
|
38
|
+
failures were real.
|
|
39
|
+
|
|
40
|
+
|
|
9
41
|
## [0.14.0] — 2026-09-02
|
|
10
42
|
|
|
11
43
|
Four defects, every one of them found by WATCHING this tool work rather than by reading it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "staysfixed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers \u2014 as a CLI and as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/guard/api.js
CHANGED
|
@@ -32,6 +32,34 @@ export class ExpectationFailed extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* This guard cannot answer its question on this machine, and that is not a verdict.
|
|
37
|
+
*
|
|
38
|
+
* Added 2026-09-02, after fifty guards were written for a real product in one night and four
|
|
39
|
+
* of them failed for reasons that had nothing to do with the product: one wanted a paired
|
|
40
|
+
* machine, one an Android phone, two a session in a particular state. Nothing was broken. The
|
|
41
|
+
* guards were simply being run somewhere that could not answer them.
|
|
42
|
+
*
|
|
43
|
+
* Until this existed a guard in that position had two ways out and both were wrong. Passing is
|
|
44
|
+
* a lie — it reports that a bug did not come back, having checked nothing. Failing is a false
|
|
45
|
+
* alarm, and worse than it sounds: the line a run prints for a failed guard is "bugs that were
|
|
46
|
+
* already fixed are back", so somebody goes hunting a regression that never happened, and after
|
|
47
|
+
* it happens twice they stop believing any of them.
|
|
48
|
+
*
|
|
49
|
+
* "Not proved" is a third answer and it is the honest one. It is the same distance from a pass
|
|
50
|
+
* as "nothing was compared" is from "nothing changed", which is the distinction this whole tool
|
|
51
|
+
* is built on, and it had a hole in it exactly here.
|
|
52
|
+
*/
|
|
53
|
+
export class GuardCannotRunHere extends Error {
|
|
54
|
+
/** @param {string} why Plain English: what is missing, and what would let it run. */
|
|
55
|
+
constructor(why) {
|
|
56
|
+
super(why);
|
|
57
|
+
this.name = 'GuardCannotRunHere';
|
|
58
|
+
/** @type {string} */
|
|
59
|
+
this.why = why;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
35
63
|
/**
|
|
36
64
|
* The run has given up on this guard, and the guard is still going.
|
|
37
65
|
*
|
|
@@ -199,6 +227,21 @@ export function makeGuardApi(page, project, opts = {}) {
|
|
|
199
227
|
page: refusable(page),
|
|
200
228
|
project,
|
|
201
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Say this guard cannot be answered here, and stop.
|
|
232
|
+
*
|
|
233
|
+
* Use it for what the machine is missing, never for what the product is doing: no phone
|
|
234
|
+
* attached, no second machine paired, no signed-in account to switch between. A guard that
|
|
235
|
+
* reaches for this because the product looks wrong has turned a real finding into silence.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} why What is missing, and what would let it run.
|
|
238
|
+
* @returns {never}
|
|
239
|
+
*/
|
|
240
|
+
cannotRunHere(why) {
|
|
241
|
+
announce(ACTION, `cannot run here: ${short(String(why))}`)('ok');
|
|
242
|
+
throw new GuardCannotRunHere(String(why));
|
|
243
|
+
},
|
|
244
|
+
|
|
202
245
|
/**
|
|
203
246
|
* @param {string} to
|
|
204
247
|
* @returns {Promise<void>}
|
package/src/guard/run.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* this one is not" is most of the value of running it at all.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { makeGuardApi, ExpectationFailed, GuardAbandoned } from './api.js';
|
|
19
|
+
import { makeGuardApi, ExpectationFailed, GuardAbandoned, GuardCannotRunHere } from './api.js';
|
|
20
20
|
import { resetWindow } from '../drive/launch.js';
|
|
21
21
|
import { emitEvent } from '../core/events.js';
|
|
22
22
|
|
|
@@ -29,6 +29,7 @@ const FRESH_KEY = 'fresh';
|
|
|
29
29
|
* @typedef {import('../types.js').GuardResult & {
|
|
30
30
|
* retriedToPass?: boolean,
|
|
31
31
|
* assertedNothing?: boolean,
|
|
32
|
+
* cannotRunHere?: boolean,
|
|
32
33
|
* timedOut?: boolean,
|
|
33
34
|
* checks?: import('../types.js').CheckStep[],
|
|
34
35
|
* }} GuardRunResult
|
|
@@ -41,6 +42,9 @@ const FRESH_KEY = 'fresh';
|
|
|
41
42
|
* @property {string} [failedAt]
|
|
42
43
|
* @property {boolean} [timedOut] The clock ran out before the guard answered. Not the same
|
|
43
44
|
* as the answer being no — see the note on the result below.
|
|
45
|
+
* @property {boolean} [cannotRunHere] The guard said this machine cannot answer it and stopped.
|
|
46
|
+
* `ok` is true because nothing went wrong; the flag is what stops
|
|
47
|
+
* it being counted as a bug that did not come back.
|
|
44
48
|
*/
|
|
45
49
|
|
|
46
50
|
/** @typedef {(step: import('../types.js').CheckStep) => void} StepSink */
|
|
@@ -150,12 +154,16 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
150
154
|
// Its OWN questions, not the runner's. Every guard gets a "fresh start" step from this
|
|
151
155
|
// file whether it asks anything or not, so counting the whole list would always find one.
|
|
152
156
|
const asked = checks.filter((c) => c.key !== FRESH_KEY && !String(c.key ?? '').endsWith(`-${FRESH_KEY}`));
|
|
153
|
-
|
|
157
|
+
// A guard that stopped because this machine cannot answer it did not "check nothing" in the
|
|
158
|
+
// sense the rule below is about — it deliberately declined to claim anything, which is the
|
|
159
|
+
// opposite failure and has to be told apart from it.
|
|
160
|
+
const couldNotRun = outcome.cannotRunHere === true;
|
|
161
|
+
const assertedNothing = outcome.ok && asked.length === 0 && !couldNotRun;
|
|
154
162
|
|
|
155
163
|
/** @type {GuardRunResult} */
|
|
156
164
|
const result = {
|
|
157
165
|
name: guard.name,
|
|
158
|
-
status: outcome.ok && !assertedNothing ? 'passed' : 'failed',
|
|
166
|
+
status: couldNotRun ? 'skipped' : outcome.ok && !assertedNothing ? 'passed' : 'failed',
|
|
159
167
|
file: guard.file,
|
|
160
168
|
because: guard.because,
|
|
161
169
|
durationMs: Date.now() - startedAt,
|
|
@@ -163,7 +171,13 @@ export async function runGuards(project, app, guards, opts = {}) {
|
|
|
163
171
|
};
|
|
164
172
|
if (checks.length > 0) result.checks = checks;
|
|
165
173
|
|
|
166
|
-
if (
|
|
174
|
+
if (couldNotRun) {
|
|
175
|
+
// Neither a pass nor a failure, and it must not be summarised as either. The message
|
|
176
|
+
// carries what is missing so somebody reading a run can tell at a glance whether it is
|
|
177
|
+
// worth plugging a phone in, or whether this guard will never run on a build machine.
|
|
178
|
+
result.cannotRunHere = true;
|
|
179
|
+
result.message = outcome.message ?? 'This guard could not be answered on this machine.';
|
|
180
|
+
} else if (assertedNothing) {
|
|
167
181
|
result.assertedNothing = true;
|
|
168
182
|
// The story of the bug is not repeated in here. It travels on `because`, and the console,
|
|
169
183
|
// the HTML report, the MCP answer and the live panel each print it themselves — so
|
|
@@ -368,6 +382,22 @@ async function attemptGuard(project, app, guard, baseUrl, timeoutMs, onStep) {
|
|
|
368
382
|
message: `This should still be true, and it is not: "${error.claim}".${consoleNote(app)}`,
|
|
369
383
|
};
|
|
370
384
|
}
|
|
385
|
+
// NOT PROVED, which is neither of the two answers above it.
|
|
386
|
+
//
|
|
387
|
+
// The guard asked for something this machine does not have — a paired machine, a phone, a
|
|
388
|
+
// second signed-in account — and stopped. Nothing about the product was learned, so nothing
|
|
389
|
+
// about the product may be reported. Passing here would be the false all-clear this whole
|
|
390
|
+
// tool exists to prevent, and failing would say "a bug that was already fixed is back" about
|
|
391
|
+
// a bug nobody looked for.
|
|
392
|
+
if (error instanceof GuardCannotRunHere) {
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
cannotRunHere: true,
|
|
396
|
+
message:
|
|
397
|
+
`Not proved here: ${error.why} Nothing about this bug was checked on this run, so nothing here ` +
|
|
398
|
+
`says it has not come back.`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
371
401
|
// Out of time is its own answer, and it is not "no". The guard was still going when the
|
|
372
402
|
// clock stopped, so all anyone knows is that nobody asked it anything it managed to
|
|
373
403
|
// finish. Said in those words rather than as a returned bug.
|
package/src/report/console.js
CHANGED
|
@@ -127,17 +127,25 @@ function shorten(s, max) {
|
|
|
127
127
|
* - `unanswered` — nobody got an answer: it ran out of time, or it asserted nothing at all.
|
|
128
128
|
* Not a pass, and not a returned bug either.
|
|
129
129
|
* - `left out` — marked skip. Never ran.
|
|
130
|
+
* - `not proved` — the guard asked for something this machine has not got and declined: no
|
|
131
|
+
* phone attached, no machine paired, no server. Distinct from `left out`,
|
|
132
|
+
* which is somebody deliberately switching a guard off. Somebody reading
|
|
133
|
+
* "left out on purpose" against a guard that WANTED to run and could not
|
|
134
|
+
* would go looking for the person who disabled it.
|
|
130
135
|
* - `held` — asked, and the answer was yes.
|
|
131
136
|
*
|
|
132
137
|
* Anything unrecognised counts as `unanswered`, never as `held`: the one thing that must
|
|
133
138
|
* never happen here is a result nobody understood being read as a clean bill of health.
|
|
134
139
|
*
|
|
135
140
|
* @param {import('../types.js').GuardResult} guard
|
|
136
|
-
* @returns {'held'|'back'|'unanswered'|'left out'}
|
|
141
|
+
* @returns {'held'|'back'|'unanswered'|'left out'|'not proved'}
|
|
137
142
|
*/
|
|
138
143
|
export function guardVerdict(guard) {
|
|
139
144
|
const g = /** @type {any} */ (guard ?? {});
|
|
140
145
|
if (g.status === 'passed') return 'held';
|
|
146
|
+
// Read before the plain 'skipped' below it: both wear that status and they mean opposite
|
|
147
|
+
// things about whether anybody wanted this guard to run.
|
|
148
|
+
if (g.cannotRunHere === true) return 'not proved';
|
|
141
149
|
if (g.status === 'skipped') return 'left out';
|
|
142
150
|
if (g.timedOut === true || g.assertedNothing === true) return 'unanswered';
|
|
143
151
|
if (g.status === 'failed') return 'back';
|
|
@@ -162,6 +170,10 @@ function guardOutcome(g) {
|
|
|
162
170
|
return 'still holds';
|
|
163
171
|
case 'left out':
|
|
164
172
|
return 'left out on purpose';
|
|
173
|
+
case 'not proved':
|
|
174
|
+
// The reason, not a fixed phrase: what is missing is the only thing worth reading here,
|
|
175
|
+
// and it is the difference between "plug a phone in" and "this will never run on CI".
|
|
176
|
+
return shorten(String(g.message || 'could not be answered on this machine'), 90);
|
|
165
177
|
case 'unanswered':
|
|
166
178
|
if (any.timedOut === true) return 'ran out of time — nothing was proved either way';
|
|
167
179
|
if (any.assertedNothing === true) return 'checks nothing, so it is protecting nothing';
|
|
@@ -387,6 +399,16 @@ export function printGuardResult(r) {
|
|
|
387
399
|
say(`${paint.grey(sym(mark.info))} ${paint.grey(`${name} left out on purpose`)}`);
|
|
388
400
|
return;
|
|
389
401
|
}
|
|
402
|
+
// NOT PROVED gets its own line, and deliberately NOT the story of the bug underneath it.
|
|
403
|
+
// The story is printed to say whether a failure matters; under a guard that never asked its
|
|
404
|
+
// question, "why this guard exists: long messages used to vanish" reads as that bug being
|
|
405
|
+
// back — which is exactly the impression this whole file exists to prevent. What belongs
|
|
406
|
+
// here is what is missing, so somebody can decide whether to go and plug it in.
|
|
407
|
+
if (verdict === 'not proved') {
|
|
408
|
+
say(`${paint.yellow(sym(mark.warn))} ${paint.yellow(`${name} ${r.message || 'could not be answered on this machine'}`)} ${time}`);
|
|
409
|
+
if (r.file) detail(` ${shortPath(r.file)}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
390
412
|
// A question nobody answered is not painted like a bug coming back. It still keeps the run
|
|
391
413
|
// out of the green — `allClear` counts it — but the colour a person scans for should not
|
|
392
414
|
// say "regression" about something the run has no opinion on.
|
package/src/types.js
CHANGED
|
@@ -273,6 +273,9 @@
|
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
275
|
* @typedef {object} GuardApi
|
|
276
|
+
* @property {(why: string) => never} cannotRunHere Stop, saying this machine cannot answer
|
|
277
|
+
* this guard. For what the MACHINE is
|
|
278
|
+
* missing, never for what the product does.
|
|
276
279
|
* @property {PageApi} page Full page control.
|
|
277
280
|
* @property {(path: string) => Promise<void>} open Shorthand for page.goto.
|
|
278
281
|
* @property {(selector: string) => Promise<void>} click
|
|
@@ -506,6 +509,11 @@ export {};
|
|
|
506
509
|
* as a pass — but it is not a bug coming back, and anything
|
|
507
510
|
* drawing this stream has to be able to tell the two apart.
|
|
508
511
|
* @property {boolean} [assertedNothing] A guard that finished without asking a single question.
|
|
512
|
+
* @property {boolean} [cannotRunHere] A guard that stopped because this machine cannot answer it —
|
|
513
|
+
* no phone attached, no second machine paired, no second account
|
|
514
|
+
* to switch between. Reported with the status 'skipped': it is
|
|
515
|
+
* not a pass, because nothing about the bug was checked, and it
|
|
516
|
+
* is not a failure, because nothing about the product went wrong.
|
|
509
517
|
* @property {string} [thumbnail] A small JPEG as a data: URI — an instant preview, shown
|
|
510
518
|
* while the real file is still being written.
|
|
511
519
|
* @property {string} [shotFile] file:// URL of the FULL-RESOLUTION picture just taken.
|