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 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.14.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
- const assertedNothing = outcome.ok && asked.length === 0;
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 (assertedNothing) {
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.
@@ -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.